diff --git a/.env.example b/.env.example index 47e52a0..7865414 100644 --- a/.env.example +++ b/.env.example @@ -69,15 +69,18 @@ WINRM_RETRY_COUNT=3 # ========== Gateway 配置 ========== GATEWAY_ENABLED=False -GATEWAY_CONTROL_SOCKET=/run/zasca/control.sock +GATEWAY_CONTROL_SOCKET=/run/2c2a/control.sock # ========== Beta数据库配置(Beta推送插件) ========== -# 配置后可将生产数据推送到Beta版本数据库,支持MySQL和PostgreSQL架构 +# 配置后可将生产数据推送到Beta版本数据库,仅支持PostgreSQL架构 #BETA_DB_NAME=zasca_beta -#BETA_DB_USER=root +#BETA_DB_USER=postgres #BETA_DB_PASSWORD=your_beta_database_password_here #BETA_DB_HOST=127.0.0.1 -#BETA_DB_PORT=3306 +#BETA_DB_PORT=5432 +# Beta环境的SECRET_KEY(用于重加密:生产密钥解密 → Beta密钥加密) +# 若不配置则直接复制密文,Beta端需使用与生产相同的SECRET_KEY才能解密 +#BETA_SECRET_KEY= # ========== Bootstrap 认证配置 ========== BOOTSTRAP_SHARED_SALT= diff --git a/LICENSE b/LICENSE index eabc293..daf412f 100755 --- a/LICENSE +++ b/LICENSE @@ -661,12 +661,12 @@ For more information, and how to apply and follow the GNU AGPL, see . - ZASCA PLUGIN EXCEPTION TO THE + 2c2a PLUGIN EXCEPTION TO THE GNU AFFERO GENERAL PUBLIC LICENSE VERSION 3 This Plugin Exception ("Exception") is an additional permission under Section 7 of the GNU Affero General Public License, Version 3 ("AGPLv3"). -It applies to the software known as ZASCA (the "Program"). This Exception +It applies to the software known as 2c2a (the "Program"). This Exception modifies the AGPLv3 as it applies to the Program by providing an exception for qualifying Plugins, as defined below. @@ -876,35 +876,35 @@ the following notices: a prominent comment at the beginning of the file containing: (i) A statement identifying the work as a Plugin for the - ZASCA program; + 2c2a program; (ii) The Plugin's own license terms; and (iii) The following declaration: - "This is a plugin for the ZASCA program. ZASCA is licensed + "This is a plugin for the 2c2a program. 2c2a is licensed under the GNU Affero General Public License, Version 3 - (AGPLv3). This plugin is NOT part of ZASCA and is NOT - licensed under the AGPLv3. The AGPLv3 applies to ZASCA - itself, not to this plugin. This plugin uses the ZASCA + (AGPLv3). This plugin is NOT part of 2c2a and is NOT + licensed under the AGPLv3. The AGPLv3 applies to 2c2a + itself, not to this plugin. This plugin uses the 2c2a Plugin Exception to the AGPLv3 for its interaction with - ZASCA." + 2c2a." (b) Documentation Notice. Any documentation, README, or distribution materials for the Plugin must prominently state: - (i) That the work is a Plugin for the ZASCA program; - (ii) That ZASCA is licensed under the AGPLv3; - (iii) That the Plugin is NOT part of ZASCA and is NOT - subject to the AGPLv3 by virtue of the ZASCA Plugin + (i) That the work is a Plugin for the 2c2a program; + (ii) That 2c2a is licensed under the AGPLv3; + (iii) That the Plugin is NOT part of 2c2a and is NOT + subject to the AGPLv3 by virtue of the 2c2a Plugin Exception; (iv) The Plugin's own license terms; and - (v) That the AGPLv3 applies to ZASCA itself, not to the + (v) That the AGPLv3 applies to 2c2a itself, not to the Plugin. (c) Runtime Notice. If the Plugin provides any user-facing interface (including but not limited to: web pages, API responses, CLI output, or log messages), it must display or - include a notice indicating that it is a Plugin for ZASCA and - that ZASCA's license (AGPLv3) does not apply to the Plugin. + include a notice indicating that it is a Plugin for 2c2a and + that 2c2a's license (AGPLv3) does not apply to the Plugin. (d) Visual Interface Attribution. In addition to the source code and documentation notices required by subsections (a) and (b), @@ -926,7 +926,7 @@ the following notices: HTML source code, comments, or developer tools. The label must include at minimum the Plugin's name and a statement that the content is provided by a third-party - Plugin (e.g., "Provided by [Plugin Name] (ZASCA + Plugin (e.g., "Provided by [Plugin Name] (2c2a Plugin)"). (ii) Injected UI Panels and Widgets. Any panel, widget, @@ -951,14 +951,14 @@ the following notices: mechanism must include a visible notice (such as a footer, header badge, or sidebar indicator) stating that the page is provided by the Plugin and is not part - of ZASCA's core interface. + of 2c2a's core interface. (v) General Principle. The overarching requirement is that no UI content injected by a Plugin may appear to the - end user as though it is a native part of the ZASCA + end user as though it is a native part of the 2c2a program. The attribution must be sufficient for a typical user to distinguish Plugin-provided UI elements - from ZASCA's core interface elements. Minimal or + from 2c2a's core interface elements. Minimal or hard-to-notice attributions (such as light-colored small text, hidden metadata, or code comments) do not satisfy this requirement. The attribution must be @@ -1012,7 +1012,7 @@ the following notices: 5. SCOPE AND LIMITATIONS - (a) This Exception applies only to the Program known as ZASCA, as + (a) This Exception applies only to the Program known as 2c2a, as identified by its copyright holders. It does not apply to any other software licensed under the AGPLv3. diff --git a/README.md b/README.md index a25c582..a4c9bac 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ ![2c2a Logo](./docs/images/logo.svg) -

2c2a - Zero Agent Security Control Architecture

+

2c2a - +Cloudy Computer Account Activation Integration Platform

基于 Django 的企业级 Windows 主机远程管理平台
diff --git a/apps/accounts/captcha_service.py b/apps/accounts/captcha_service.py index ba39a31..f032ffc 100644 --- a/apps/accounts/captcha_service.py +++ b/apps/accounts/captcha_service.py @@ -1,7 +1,6 @@ import logging from typing import Tuple, Optional from django.http import HttpRequest -from . import geetest_utils, captcha_utils logger = logging.getLogger(__name__) @@ -22,15 +21,11 @@ def validate_captcha( ) -> Tuple[bool, Optional[str]]: from apps.dashboard.models import SystemConfig - provider, _, _ = SystemConfig.get_config().get_captcha_config(scene=scene) + provider = SystemConfig.get_config().get_captcha_config(scene=scene) try: - if provider == 'geetest': - return CaptchaService._validate_geetest(request) - elif provider == 'turnstile': - return CaptchaService._validate_turnstile(request) - elif provider == 'local': - return CaptchaService._validate_local_captcha(request) + if provider == 'tianai': + return CaptchaService._validate_tianai(request) else: logger.debug(f"No captcha validation required for scene '{scene}', provider: {provider}") return True, None @@ -40,65 +35,23 @@ def validate_captcha( return False, e.message @staticmethod - def _validate_geetest(request: HttpRequest) -> Tuple[bool, Optional[str]]: - lot_number = request.POST.get('lot_number') - captcha_output = request.POST.get('captcha_output') - pass_token = request.POST.get('pass_token') - gen_time = request.POST.get('gen_time') - captcha_id = request.POST.get('captcha_id') - - if not all([lot_number, captcha_output, pass_token, gen_time]): - logger.warning(f"Geetest validation failed: missing parameters - lot_number={lot_number}, captcha_output={captcha_output}, pass_token={pass_token}, gen_time={gen_time}") - raise CaptchaValidationError('请完成验证码验证') - - ok, resp = geetest_utils.verify_geetest_v4( - lot_number, captcha_output, pass_token, gen_time, captcha_id=captcha_id - ) - - if not ok: - logger.warning(f"Geetest validation failed: {resp}") - raise CaptchaValidationError('验证码校验失败') - - logger.info("Geetest validation succeeded") - return True, None - - @staticmethod - def _validate_turnstile(request: HttpRequest) -> Tuple[bool, Optional[str]]: - tf_token = request.POST.get('cf-turnstile-response') or request.POST.get('turnstile_token') - - if not tf_token: - logger.warning("Turnstile validation failed: missing token") - raise CaptchaValidationError('请完成 Turnstile 验证') + def _validate_tianai(request: HttpRequest) -> Tuple[bool, Optional[str]]: + token = request.POST.get('captcha_token') - ok, resp = geetest_utils.verify_turnstile( - tf_token, remoteip=request.META.get('REMOTE_ADDR') - ) - - if not ok: - logger.warning(f"Turnstile validation failed: {resp}") - raise CaptchaValidationError('Turnstile 验证失败') - - logger.info("Turnstile validation succeeded") - return True, None - - @staticmethod - def _validate_local_captcha(request: HttpRequest) -> Tuple[bool, Optional[str]]: - lot_number = request.POST.get('lot_number') - captcha_input = request.POST.get('captcha_output') - - if not all([lot_number, captcha_input]): - logger.warning(f"Local captcha validation failed: missing parameters - lot_number={lot_number}, captcha_input={captcha_input}") + if not token: + logger.warning("Tianai captcha validation failed: missing token") raise CaptchaValidationError('请完成验证码验证') - is_valid = captcha_utils.verify_captcha( - lot_number, captcha_input, consume=True, check_attempts=True - ) + from django_tianai_captcha.conf import get_captcha_application + app = get_captcha_application() + + is_valid = app.secondary_verification(token) if not is_valid: - logger.warning(f"Local captcha validation failed: lot_number={lot_number}") - raise CaptchaValidationError('本地验证码校验失败') + logger.warning("Tianai captcha secondary verification failed") + raise CaptchaValidationError('验证码校验失败') - logger.info(f"Local captcha validation succeeded: lot_number={lot_number}") + logger.info("Tianai captcha validation succeeded") return True, None diff --git a/apps/accounts/captcha_utils.py b/apps/accounts/captcha_utils.py deleted file mode 100755 index 4cb4d7d..0000000 --- a/apps/accounts/captcha_utils.py +++ /dev/null @@ -1,234 +0,0 @@ -""" -本地图形验证码工具类 -使用Pillow生成和验证图形验证码 -""" -import random -import string -import io -import logging -from PIL import Image, ImageDraw, ImageFont, ImageFilter -from django.core.cache import cache -from django.http import HttpResponse -from django.conf import settings -import os - - -# 设置日志 -logger = logging.getLogger(__name__) - - -def generate_captcha_image(size=(120, 40), chars=None, count=4, color=True, noise=1, noise_color=True, font_size=25): - """ - 生成图形验证码图片 - - Args: - size: 图片尺寸 (宽, 高) - chars: 验证码字符集合 - count: 验证码字符数 - color: 是否使用彩色 - noise: 干扰线数量 - noise_color: 干扰线是否彩色 - font_size: 字体大小 - - Returns: - tuple: (验证码文本, 图片二进制数据) - """ - if chars is None: - chars = string.ascii_letters + string.digits # 包含大小写字母和数字 - # 排除容易混淆的字符 - chars = chars.replace('0', '').replace('O', '').replace('o', '').replace('l', '').replace('I', '').replace('i', '') - - # 随机生成验证码文本 - captcha_text = ''.join(random.choice(chars) for _ in range(count)) - - # 创建图像 - width, height = size - image = Image.new('RGB', (width, height), (255, 255, 255)) - - # 创建绘图画布 - draw = ImageDraw.Draw(image) - - # 设置字体(尝试使用系统字体) - try: - # 尝试使用系统字体 - font = ImageFont.truetype("arial.ttf", font_size) - except IOError: - try: - # 尝试其他常见字体 - font = ImageFont.truetype("/System/Library/Fonts/Arial.ttf", font_size) # macOS - except IOError: - try: - font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", font_size) # Linux - except IOError: - # 如果找不到字体,使用默认字体 - font = ImageFont.load_default() - - # 生成随机颜色 - def get_random_color(): - return (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)) - - # 绘制干扰点 - for _ in range(width * height // 4): - draw.point((random.randint(0, width), random.randint(0, height)), fill=get_random_color()) - - # 绘制干扰线 - for _ in range(noise): - start = (random.randint(0, width), random.randint(0, height)) - end = (random.randint(0, width), random.randint(0, height)) - line_color = get_random_color() if noise_color else (random.randint(0, 150), random.randint(0, 150), random.randint(0, 150)) - draw.line([start, end], fill=line_color, width=1) - - # 计算文字位置,使其居中显示 - try: - # For newer versions of Pillow - bbox = draw.textbbox((0, 0), captcha_text, font=font) - text_width = bbox[2] - bbox[0] - text_height = bbox[3] - bbox[1] - except AttributeError: - # For older versions of Pillow - text_width, text_height = draw.textsize(captcha_text, font=font) - - text_x = (width - text_width) // 2 - text_y = (height - text_height) // 2 - - # 绘制验证码文字 - for i, char in enumerate(captcha_text): - # 每个字符可能有不同的颜色和轻微的位置偏移 - char_color = get_random_color() if color else (0, 0, 0) - char_x = text_x + i * (text_width // count) - - # 添加轻微的角度偏移使字符看起来更自然 - char_image = Image.new('RGBA', (font_size, font_size * 2), (255, 255, 255, 0)) - char_draw = ImageDraw.Draw(char_image) - char_draw.text((0, 0), char, font=font, fill=char_color) - - # 旋转字符增加复杂度 - angle = random.randint(-15, 15) - char_image = char_image.rotate(angle, expand=0, fillcolor=(255, 255, 255, 0)) - - # 将旋转后的字符粘贴到主图像上 - image.paste(char_image, (char_x, text_y), char_image) - - # 对图像应用模糊效果以增加难度 - image = image.filter(ImageFilter.SMOOTH) - - # 将图像转换为字节流 - buffer = io.BytesIO() - image.save(buffer, format='PNG') - buffer.seek(0) - - return captcha_text, buffer.getvalue() - - -def generate_captcha(): - """ - 生成验证码,返回验证码文本和图片数据,并将验证码存储到缓存中 - - Returns: - dict: 包含验证码ID和图片数据的字典 - """ - captcha_text, image_data = generate_captcha_image() - - captcha_id = 'captcha_' + ''.join(random.choices(string.ascii_letters + string.digits, k=16)) - - cache.set(captcha_id, captcha_text.lower(), 300) - cache.set(f"captcha_attempts_{captcha_id}", 0, 300) - cache.set(f"captcha_image_{captcha_id}", image_data, 300) - - logger.debug(f"Generated captcha: ID={captcha_id}") - - return { - 'captcha_id': captcha_id, - 'image_data': image_data - } - - -def verify_captcha(captcha_id, user_input, consume=True, max_attempts=5, check_attempts=True): - """ - 验证用户输入的验证码是否正确 - - Args: - captcha_id: 验证码ID - user_input: 用户输入的验证码 - consume: 是否在验证成功后删除验证码(默认为True) - max_attempts: 最大尝试次数(默认为5次) - check_attempts: 是否检查尝试次数(默认为True) - - Returns: - bool: 验证是否成功 - """ - logger.debug(f"Verifying captcha: ID={captcha_id}, user_input={user_input}, consume={consume}, check_attempts={check_attempts}") - - if not captcha_id or not user_input: - logger.warning(f"Invalid input: captcha_id='{captcha_id}', user_input='{user_input}'") - return False - - # 检查尝试次数 - if check_attempts: - attempts_key = f"captcha_attempts_{captcha_id}" - attempts = cache.get(attempts_key, 0) - - logger.debug(f"Captcha {captcha_id} attempts: {attempts}/{max_attempts}") - - # 如果已达到最大尝试次数,拒绝进一步尝试 - if attempts >= max_attempts: - logger.warning(f"Captcha {captcha_id} reached max attempts ({max_attempts}). Invalidating captcha.") - cache.delete(captcha_id) - cache.delete(attempts_key) - cache.delete(f"captcha_image_{captcha_id}") - return False - - # 增加尝试次数(兼容 locmem 等不支持 incr 对不存在 key 操作的缓存后端) - current_attempts = cache.get(attempts_key) - if current_attempts is None: - cache.set(attempts_key, 1, 300) - else: - cache.incr(attempts_key) - logger.debug(f"Incremented attempts for {captcha_id}, now: {cache.get(attempts_key)}") - - # 从缓存中获取正确的验证码 - correct_captcha = cache.get(captcha_id) - - if not correct_captcha: - logger.warning(f"Correct captcha not found or expired: {captcha_id}") - return False # 验证码已过期或不存在 - - logger.debug(f"Retrieved correct captcha for {captcha_id}: {correct_captcha}") - - # 验证用户输入(不区分大小写) - is_valid = correct_captcha.lower() == user_input.lower() - logger.info(f"Captcha verification result for {captcha_id}: {is_valid}") - - if is_valid and consume: - logger.info(f"Consuming captcha {captcha_id} (deleting from cache)") - cache.delete(captcha_id) - cache.delete(f"captcha_attempts_{captcha_id}") - cache.delete(f"captcha_image_{captcha_id}") - logger.debug(f"Captcha {captcha_id} consumed and removed from cache") - - if not is_valid and check_attempts: - attempts_key = f"captcha_attempts_{captcha_id}" - current_attempts = cache.get(attempts_key, 0) - if current_attempts >= max_attempts: - logger.warning(f"Failed attempt reached max limit for {captcha_id}. Invalidating captcha.") - cache.delete(captcha_id) - cache.delete(attempts_key) - cache.delete(f"captcha_image_{captcha_id}") - - return is_valid - - -def get_captcha_image(request, captcha_id): - logger.debug(f"Serving captcha image: {captcha_id}") - - if not captcha_id: - result = generate_captcha() - image_data = result['image_data'] - else: - image_data = cache.get(f"captcha_image_{captcha_id}") - if not image_data: - logger.warning(f"Requested captcha not found: {captcha_id}, generating new one") - result = generate_captcha() - image_data = result['image_data'] - - return HttpResponse(image_data, content_type='image/png') \ No newline at end of file diff --git a/apps/accounts/geetest_utils.py b/apps/accounts/geetest_utils.py deleted file mode 100755 index e629244..0000000 --- a/apps/accounts/geetest_utils.py +++ /dev/null @@ -1,173 +0,0 @@ -""" -极验(Geetest)集成工具(仅 v4) -提供二次校验(verify)的封装和前端初始化信息 -""" -import json -import logging -import time -import requests -from django.conf import settings -import hmac -import hashlib -from apps.dashboard.models import SystemConfig - -logger = logging.getLogger('2c2a') - -# v4 validate endpoint base -GEETEST_V4_API_SERVER = 'https://gcaptcha4.geetest.com' - -# 超时配置(秒) -REQUEST_TIMEOUT = 10 - - -def _get_runtime_keys(): - """Return (captcha_id, captcha_key) by preferring SystemConfig when enabled for geetest, otherwise settings.""" - captcha_id = getattr(settings, 'GEETEST_ID', None) - captcha_key = getattr(settings, 'GEETEST_KEY', None) - - try: - config = SystemConfig.get_config() - if config and config.captcha_provider == 'geetest': - if config.captcha_id: - captcha_id = config.captcha_id - if config.captcha_key: - captcha_key = config.captcha_key - except Exception: - # if models not ready or DB unavailable, ignore and fall back to settings - pass - - return captcha_id, captcha_key - - -def get_geetest_init(request): - """Return minimal init info for frontend v4 usage. - - Returns: dict with keys: - - captcha_id: id for initGeetest4 - - enabled: whether SystemConfig enables geetest or settings present - """ - captcha_id, captcha_key = _get_runtime_keys() - - enabled = bool(captcha_id and captcha_key) - - # cache server status in session (simple) - request.session['geetest_server_status'] = enabled - request.session['geetest_server_status_ts'] = int(time.time()) - - return { - 'captcha_id': captcha_id, - 'enabled': enabled, - 'success': 1 if enabled else 0, - } - - -def verify_geetest_v4(lot_number, captcha_output, pass_token, gen_time, captcha_id=None): - """使用 Geetest v4 的二次校验接口进行服务器端验证。 - - 参考接口:POST https://gcaptcha4.geetest.com/validate?captcha_id=xxxxx - 请求体(application/x-www-form-urlencoded): - lot_number, captcha_output, pass_token, gen_time, sign_token - - sign_token = HMAC_SHA256(key=captcha_key, message=lot_number) - - 返回 (bool, message_or_response_dict) - """ - # 获取运行时的 id/key(优先使用 SystemConfig) - runtime_id, runtime_key = _get_runtime_keys() - captcha_id = captcha_id or runtime_id - captcha_key = runtime_key - - # 基本参数检查 - if not all([lot_number, captcha_output, pass_token, gen_time, captcha_id, captcha_key]): - return False, '参数不完整或未配置极验ID/Key' - - try: - # sign token: HMAC-SHA256(lot_number, captcha_key) - # 注意:hmac.new(key, message, digestmod) — key和message都应为bytes - sign_token = hmac.new(captcha_key.encode('utf-8'), lot_number.encode('utf-8'), digestmod=hashlib.sha256).hexdigest() - - data = { - 'lot_number': lot_number, - 'captcha_output': captcha_output, - 'pass_token': pass_token, - 'gen_time': gen_time, - 'sign_token': sign_token, - } - - url = f'{GEETEST_V4_API_SERVER}/validate' - # 添加captcha_id作为查询参数 - params = {'captcha_id': captcha_id} - - # 使用 application/x-www-form-urlencoded 提交 - r = requests.post(url, data=data, params=params, timeout=REQUEST_TIMEOUT) - r.raise_for_status() - - # 解析 JSON 响应 - try: - resp = r.json() - except ValueError: - # 非 JSON 返回 - logger.error('Geetest v4 validate returned non-JSON response: %s', r.text) - return False, {'status': 'error', 'msg': '非JSON响应', 'raw': r.text} - - # 成功时 resp['result'] == 'success' (注意:文档中提到的是result而不是status) - result = resp.get('result') - reason = resp.get('reason', '') - - if result == 'success': - return True, resp - else: - error_msg = f'验证码校验失败: {reason}' if reason else f'验证码校验失败: {resp}' - logger.warning(f'Geetest v4 validation failed: {error_msg}') - return False, resp - - except requests.Timeout: - logger.error('Geetest v4 validate request timed out') - return False, {'status': 'error', 'reason': '请求超时'} - except requests.RequestException as e: - logger.exception('请求 Geetest v4 校验接口失败: %s', e) - return False, {'status': 'error', 'reason': '请求 geetest API 失败'} - except Exception as e: - logger.exception('Geetest v4 verify unexpected error: %s', e) - return False, {'status': 'error', 'reason': '验证异常'} - - -def verify_turnstile(response_token, remoteip=None): - """Verify Cloudflare Turnstile token server-side. - - POST https://challenges.cloudflare.com/turnstile/v0/siteverify - params: secret, response, remoteip (optional) - - Returns (bool, resp_dict) - """ - secret = getattr(settings, 'TURNSTILE_SECRET_KEY', None) - try: - cfg = SystemConfig.get_config() - if cfg and cfg.captcha_provider == 'turnstile' and cfg.captcha_key: - secret = cfg.captcha_key - except Exception: - pass - - if not secret or not response_token: - return False, {'success': False, 'error': 'missing secret or response'} - - try: - url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify' - data = { - 'secret': secret, - 'response': response_token, - } - if remoteip: - data['remoteip'] = remoteip - - r = requests.post(url, data=data, timeout=REQUEST_TIMEOUT) - r.raise_for_status() - resp = r.json() - # resp['success'] is True/False - return bool(resp.get('success')), resp - except requests.RequestException as e: - logger.exception('Turnstile verify request failed: %s', e) - return False, {'success': False, 'error': 'request failed'} - except Exception as e: - logger.exception('Turnstile verify unexpected error: %s', e) - return False, {'success': False, 'error': 'unexpected error'} \ No newline at end of file diff --git a/apps/accounts/urls.py b/apps/accounts/urls.py index 310f344..9a8d263 100755 --- a/apps/accounts/urls.py +++ b/apps/accounts/urls.py @@ -1,6 +1,3 @@ -""" -用户管理URL配置 -""" from django.urls import path from django.views.decorators.cache import never_cache from . import views @@ -13,17 +10,9 @@ path('login/', never_cache(views.LoginView.as_view()), name='login'), path('profile/', views.ProfileView.as_view(), name='profile'), path('logout/', views.logout_view, name='logout'), - # Geetest endpoints - path('geetest/register/', views.geetest_register, name='geetest_register'), - path('geetest/validate/', views.geetest_validate, name='geetest_validate'), path('email/send-code/', views.send_register_email_code, name='send_register_email_code'), path('forgot-password/', views.ForgotPasswordView.as_view(), name='forgot_password'), path('email/send-forgot-password-code/', views.send_forgot_password_email_code, name='send_forgot_password_email_code'), - # Local Captcha endpoints - path('captcha/generate/', views.local_captcha_generate, name='local_captcha_generate'), - path('captcha/image//', views.local_captcha_image, name='local_captcha_image'), - path('captcha/verify/', views.local_captcha_verify, name='local_captcha_verify'), path('api/profile/avatar/', views.upload_avatar, name='upload_avatar'), - # API endpoints path('api/password/change/', views.password_change_api, name='password_change_api'), -] \ No newline at end of file +] diff --git a/apps/accounts/views.py b/apps/accounts/views.py index 33314b3..fc31e29 100755 --- a/apps/accounts/views.py +++ b/apps/accounts/views.py @@ -18,8 +18,6 @@ from .models import User, RegistrationLink from .forms import UserRegistrationForm, UserUpdateForm, UserLoginForm -from . import geetest_utils -from . import captcha_utils from . import rate_limit from apps.themes.models import ThemeConfig, PageContent @@ -35,6 +33,20 @@ def get_theme_context(): } +def get_captcha_context(scene): + from apps.dashboard.models import SystemConfig + sc = SystemConfig.get_config() + captcha_provider, captcha_type = sc.get_captcha_config(scene=scene) + ctx = { + 'CAPTCHA_PROVIDER': captcha_provider, + 'CAPTCHA_TYPE': captcha_type, + } + if scene in ('register', 'forgot_password'): + _, email_type = sc.get_captcha_config(scene='email') + ctx['CAPTCHA_TYPE_EMAIL'] = email_type + return ctx + + @method_decorator(rate_limit.register_rate_limit, name='dispatch') class RegisterView(CreateView): """用户注册视图""" @@ -46,22 +58,8 @@ class RegisterView(CreateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - from apps.dashboard.models import SystemConfig - sc = SystemConfig.get_config() - # 使用与后端验证相同的逻辑来确定captcha_id - captcha_id, _ = geetest_utils._get_runtime_keys() - context['GEETEST_ID'] = captcha_id - # 获取注册场景的配置 - captcha_provider, _, captcha_key = sc.get_captcha_config(scene='register') - context['CAPTCHA_PROVIDER'] = captcha_provider - # 仅在turnstile模式下提供turnstile的site key - if captcha_provider == 'turnstile': - context['TURNSTILE_SITE_KEY'] = captcha_key - else: - context['TURNSTILE_SITE_KEY'] = None - + context.update(get_captcha_context('register')) context.update(get_theme_context()) - return context def form_valid(self, form): @@ -78,8 +76,11 @@ def form_valid(self, form): import hmac cache_key = f'register_email_code:{email}' expected = cache.get(cache_key) - if not hmac.compare_digest(str(expected or ''), str(email_code or '')): - form.add_error(None, '邮箱验证码错误或已过期') + if expected is None: + form.add_error(None, '邮箱验证码已过期或不存在') + return self.form_invalid(form) + if not hmac.compare_digest(str(expected), str(email_code)): + form.add_error(None, '邮箱验证码错误') return self.form_invalid(form) # Optionally clear the code to prevent reuse @@ -111,22 +112,10 @@ def get_context_data(self, **kwargs): """获取模板上下文数据""" context = super().get_context_data(**kwargs) context['form'] = UserLoginForm() - from apps.dashboard.models import SystemConfig - sc = SystemConfig.get_config() - # 使用场景化配置获取登录场景的验证码设置 - captcha_provider, captcha_id, captcha_key = sc.get_captcha_config(scene='login') - context['GEETEST_ID'] = captcha_id - context['CAPTCHA_PROVIDER'] = captcha_provider - if captcha_provider == 'turnstile': - context['TURNSTILE_SITE_KEY'] = captcha_key - else: - context['TURNSTILE_SITE_KEY'] = None - + context.update(get_captcha_context('login')) context['is_demo_mode'] = getattr(self.request, 'is_demo_mode', False) context['next'] = self.request.POST.get('next') or self.request.GET.get('next', '') - context.update(get_theme_context()) - return context def post(self, request, *args, **kwargs): @@ -281,42 +270,6 @@ def logout_view(request): return redirect('accounts:login') -# Geetest endpoints -@require_http_methods(['GET']) -def geetest_register(request): - """为前端提供极验初始化参数(JSON)""" - data = geetest_utils.get_geetest_init(request) - return JsonResponse(data) - - -@require_http_methods(['POST']) -@csrf_protect -@rate_limit.general_api_rate_limit -def geetest_validate(request): - """可以做一次性的验证接口(可选)。 - 前端可直接把三个字段POST到此处获取验证结果 - """ - # 支持 v4 参数 - # (lot_number / captcha_output / pass_token / gen_time / captcha_id) - lot_number = request.POST.get('lot_number') - captcha_output = request.POST.get('captcha_output') - pass_token = request.POST.get('pass_token') - gen_time = request.POST.get('gen_time') - captcha_id = request.POST.get('captcha_id') - - if lot_number and captcha_output and pass_token and gen_time: - ok, resp = geetest_utils.verify_geetest_v4( - lot_number, captcha_output, pass_token, gen_time, - captcha_id=captcha_id - ) - if ok: - return JsonResponse({'result': 'ok', 'detail': resp}) - else: - return JsonResponse({'result': 'fail', 'detail': resp}, status=400) - - return JsonResponse({'result': 'fail', 'detail': '参数不完整'}, status=400) - - @login_required @require_http_methods(["POST"]) @rate_limit.general_api_rate_limit @@ -366,14 +319,7 @@ def _gen_code(length=6): @csrf_protect @rate_limit.email_code_rate_limit def send_register_email_code(request): - """Send a one-time code to the supplied email for registration. - - Requires behavior captcha validation to have been passed in this session - if captcha_provider == 'geetest' or 'turnstile' - (adapter should call /accounts/geetest/validate/ first and backend can - check session or just trust front-end - here we trust front-end token - by requiring v4 params in this request). - """ + """Send a one-time code to the supplied email for registration.""" # 检查是否启用了注册功能 from apps.dashboard.models import SystemConfig cfg = SystemConfig.get_config() @@ -557,18 +503,7 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['reglink'] = self.reglink context['target_group'] = self.reglink.group - from apps.dashboard.models import SystemConfig - sc = SystemConfig.get_config() - # 获取邮箱验证码场景的配置(获取验证码需要行为验证) - captcha_provider, captcha_id, captcha_key = sc.get_captcha_config( - scene='email' - ) - context['GEETEST_ID'] = captcha_id - context['CAPTCHA_PROVIDER'] = captcha_provider - if captcha_provider == 'turnstile': - context['TURNSTILE_SITE_KEY'] = captcha_key - else: - context['TURNSTILE_SITE_KEY'] = None + context.update(get_captcha_context('email')) context.update(get_theme_context()) return context @@ -582,10 +517,11 @@ def form_valid(self, form): cache_key = f'register_email_code:{email}' expected = cache.get(cache_key) - if not hmac.compare_digest( - str(expected or ''), str(email_code or '') - ): - form.add_error(None, '邮箱验证码错误或已过期') + if expected is None: + form.add_error(None, '邮箱验证码已过期或不存在') + return self.form_invalid(form) + if not hmac.compare_digest(str(expected), str(email_code)): + form.add_error(None, '邮箱验证码错误') return self.form_invalid(form) cache.delete(cache_key) @@ -670,36 +606,6 @@ def upload_avatar(request): return JsonResponse({'status': 'error', 'message': '没有上传文件'}) -# Local Captcha endpoints -@require_http_methods(['GET']) -def local_captcha_generate(request): - """Generate a local image captcha and return the captcha ID""" - result = captcha_utils.generate_captcha() - return JsonResponse({'captcha_id': result['captcha_id']}) - - -def local_captcha_image(request, captcha_id): - """Return the image for the given captcha ID""" - return captcha_utils.get_captcha_image(request, captcha_id) - - -@require_http_methods(['POST']) -@rate_limit.general_api_rate_limit -def local_captcha_verify(request): - """Verify the user's input against the captcha""" - captcha_id = request.POST.get('captcha_id') - user_input = request.POST.get('captcha_input') - - # 验证时设置consume=False,这样验证后不会删除,可用于后续的表单提交验证 - # 设置较低的尝试次数限制,防止暴力破解 - if captcha_utils.verify_captcha( - captcha_id, user_input, consume=False, max_attempts=3 - ): - return JsonResponse({'result': 'success'}) - else: - return JsonResponse({'result': 'failure'}, status=400) - - @method_decorator(rate_limit.register_rate_limit, name='dispatch') class ForgotPasswordView(TemplateView): """忘记密码视图""" @@ -709,22 +615,8 @@ class ForgotPasswordView(TemplateView): def get_context_data(self, **kwargs): """获取模板上下文数据""" context = super().get_context_data(**kwargs) - from apps.dashboard.models import SystemConfig - sc = SystemConfig.get_config() - # 使用与后端验证相同的逻辑来确定captcha_id - captcha_id, _ = geetest_utils._get_runtime_keys() - context['GEETEST_ID'] = captcha_id - # 获取邮箱场景的配置 - captcha_provider, _, captcha_key = sc.get_captcha_config(scene='email') - context['CAPTCHA_PROVIDER'] = captcha_provider - # 仅在turnstile模式下提供turnstile的site key - if captcha_provider == 'turnstile': - context['TURNSTILE_SITE_KEY'] = captcha_key - else: - context['TURNSTILE_SITE_KEY'] = None - + context.update(get_captcha_context('email')) context.update(get_theme_context()) - return context def post(self, request, *args, **kwargs): @@ -739,36 +631,41 @@ def post(self, request, *args, **kwargs): messages.error(request, '请填写所有必需字段') return self.render_to_response(self.get_context_data()) - if new_password1 != new_password2: - messages.error(request, '两次输入的密码不一致') - return self.render_to_response(self.get_context_data()) - - from django.contrib.auth.password_validation import validate_password - from django.core.exceptions import ValidationError as ValError - try: - validate_password(new_password1) - except ValError as e: - messages.error(request, e.messages[0]) + # 1. 行为验证码 + from .captcha_service import validate_captcha + is_valid, error_msg = validate_captcha(request, scene='email') + if not is_valid: + messages.error(request, error_msg) return self.render_to_response(self.get_context_data()) - from .captcha_service import validate_captcha + # 2. 邮箱验证码 import hmac cache_key = f'forgot_password_email_code:{email}' expected = cache.get(cache_key) - if not hmac.compare_digest(str(expected or ''), str(email_code or '')): - messages.error(request, '验证码错误或已过期') + if expected is None: + messages.error(request, '邮箱验证码已过期或不存在') return self.render_to_response(self.get_context_data()) - + if not hmac.compare_digest(str(expected), str(email_code)): + messages.error(request, '邮箱验证码错误') + return self.render_to_response(self.get_context_data()) + + # 3. 用户存在性检查 user_exists = User.objects.filter(email=email).exists() if not user_exists: messages.success(request, '如果该邮箱已注册,密码重置邮件已发送') return redirect('accounts:login') - - from .captcha_service import validate_captcha - is_valid, error_msg = validate_captcha(request, scene='email') - if not is_valid: - messages.error(request, error_msg) + # 4. 密码重置 + if new_password1 != new_password2: + messages.error(request, '两次输入的密码不一致') + return self.render_to_response(self.get_context_data()) + + from django.contrib.auth.password_validation import validate_password + from django.core.exceptions import ValidationError as ValError + try: + validate_password(new_password1) + except ValError as e: + messages.error(request, e.messages[0]) return self.render_to_response(self.get_context_data()) user = User.objects.get(email=email) @@ -786,14 +683,7 @@ def post(self, request, *args, **kwargs): @csrf_protect @rate_limit.email_code_rate_limit def send_forgot_password_email_code(request): - """Send a one-time code to the supplied email for password reset. - - Requires behavior captcha validation to have been passed in this session - if captcha_provider == 'geetest' or 'turnstile' - (adapter should call /accounts/geetest/validate/ first and backend can - check session or just trust front-end - here we trust front-end token - by requiring v4 params in this request). - """ + """Send a one-time code to the supplied email for password reset.""" email = request.POST.get('email') if not email: diff --git a/apps/accounts/views_superadmin.py b/apps/accounts/views_superadmin.py index d5d9920..eb81c16 100644 --- a/apps/accounts/views_superadmin.py +++ b/apps/accounts/views_superadmin.py @@ -15,6 +15,7 @@ HostGroupProviderAssignForm, ) from apps.hosts.models import Host, HostGroup +from apps.hosts.views_admin import _get_permission_context @superadmin_required @@ -97,6 +98,7 @@ def superadmin_host_provider_assign(request, pk): 'current_providers': host.providers.all(), 'active_nav': 'provider_hosts', } + context.update(_get_permission_context(form, host)) return render( request, 'admin_base/providers/host_provider_assign.html', context diff --git a/apps/audit/views.py b/apps/audit/views.py index 481590c..710e78b 100755 --- a/apps/audit/views.py +++ b/apps/audit/views.py @@ -12,9 +12,35 @@ from datetime import datetime, timedelta import json import logging +import re logger = logging.getLogger(__name__) +MAX_PAGE_SIZE = 100 +MAX_SEARCH_LENGTH = 200 +DATE_FORMAT = '%Y-%m-%d' + + +def _validate_int_param(value, default=1, min_val=1, max_val=None): + try: + result = int(value) + result = max(min_val, result) + if max_val: + result = min(max_val, result) + return result + except (ValueError, TypeError): + return default + + +def _validate_date_param(value): + if not value: + return None + try: + datetime.strptime(value, DATE_FORMAT) + return value + except ValueError: + return None + @require_http_methods(["GET"]) @login_required @@ -22,16 +48,15 @@ def get_audit_logs(request): """获取审计日志列表""" try: - # 参数获取 - page = int(request.GET.get('page', 1)) - page_size = min(int(request.GET.get('page_size', 20)), 100) # 最大100条每页 - action = request.GET.get('action') - user_id = request.GET.get('user_id') - host_id = request.GET.get('host_id') - start_date = request.GET.get('start_date') - end_date = request.GET.get('end_date') + page = _validate_int_param(request.GET.get('page', 1), default=1, min_val=1, max_val=10000) + page_size = _validate_int_param(request.GET.get('page_size', 20), default=20, min_val=1, max_val=MAX_PAGE_SIZE) + action = request.GET.get('action', '')[:50] if request.GET.get('action') else None + user_id = _validate_int_param(request.GET.get('user_id'), default=None, min_val=1) if request.GET.get('user_id') else None + host_id = _validate_int_param(request.GET.get('host_id'), default=None, min_val=1) if request.GET.get('host_id') else None + start_date = _validate_date_param(request.GET.get('start_date')) + end_date = _validate_date_param(request.GET.get('end_date')) success = request.GET.get('success') - search = request.GET.get('search', '') # 搜索关键词 + search = request.GET.get('search', '')[:MAX_SEARCH_LENGTH] # 构建查询集 queryset = AuditLog.objects.select_related('user', 'host').all() @@ -118,16 +143,15 @@ def get_audit_logs(request): def get_sensitive_operations(request): """获取敏感操作记录""" try: - page = int(request.GET.get('page', 1)) - page_size = min(int(request.GET.get('page_size', 20)), 100) - user_id = request.GET.get('user_id') - operation_type = request.GET.get('operation_type') - start_date = request.GET.get('start_date') - end_date = request.GET.get('end_date') + page = _validate_int_param(request.GET.get('page', 1), default=1, min_val=1, max_val=10000) + page_size = _validate_int_param(request.GET.get('page_size', 20), default=20, min_val=1, max_val=MAX_PAGE_SIZE) + user_id = _validate_int_param(request.GET.get('user_id'), default=None, min_val=1) if request.GET.get('user_id') else None + operation_type = request.GET.get('operation_type', '')[:50] if request.GET.get('operation_type') else None + start_date = _validate_date_param(request.GET.get('start_date')) + end_date = _validate_date_param(request.GET.get('end_date')) queryset = SensitiveOperation.objects.select_related('user', 'approved_by').all() - # 应用过滤器 if user_id: queryset = queryset.filter(user_id=user_id) if operation_type: @@ -194,14 +218,14 @@ def get_sensitive_operations(request): def get_security_events(request): """获取安全事件记录""" try: - page = int(request.GET.get('page', 1)) - page_size = min(int(request.GET.get('page_size', 20)), 100) - event_type = request.GET.get('event_type') - severity = request.GET.get('severity') + page = _validate_int_param(request.GET.get('page', 1), default=1, min_val=1, max_val=10000) + page_size = _validate_int_param(request.GET.get('page_size', 20), default=20, min_val=1, max_val=MAX_PAGE_SIZE) + event_type = request.GET.get('event_type', '')[:50] if request.GET.get('event_type') else None + severity = request.GET.get('severity', '')[:20] if request.GET.get('severity') else None resolved = request.GET.get('resolved') - user_id = request.GET.get('user_id') - start_date = request.GET.get('start_date') - end_date = request.GET.get('end_date') + user_id = _validate_int_param(request.GET.get('user_id'), default=None, min_val=1) if request.GET.get('user_id') else None + start_date = _validate_date_param(request.GET.get('start_date')) + end_date = _validate_date_param(request.GET.get('end_date')) queryset = SecurityEvent.objects.select_related('user', 'resolved_by').all() @@ -292,8 +316,8 @@ def mark_security_event_resolved(request): event.resolved = True event.resolved_by = request.user if request.user.is_authenticated else None event.resolved_at = timezone.now() - event.resolution_notes = resolution_notes - event.save() + event.resolution_notes = resolution_notes[:1000] if resolution_notes else '' + event.save(update_fields=['resolved', 'resolved_by', 'resolved_at', 'resolution_notes']) return JsonResponse({ 'success': True, @@ -319,9 +343,9 @@ def mark_security_event_resolved(request): def get_user_session_activity(request): """获取用户会话活动记录""" try: - user_id = request.GET.get('user_id') - page = int(request.GET.get('page', 1)) - page_size = min(int(request.GET.get('page_size', 20)), 100) + user_id = _validate_int_param(request.GET.get('user_id'), default=None, min_val=1) if request.GET.get('user_id') else None + page = _validate_int_param(request.GET.get('page', 1), default=1, min_val=1, max_val=10000) + page_size = _validate_int_param(request.GET.get('page_size', 20), default=20, min_val=1, max_val=MAX_PAGE_SIZE) queryset = SessionActivity.objects.select_related('user').all() @@ -474,11 +498,11 @@ def export_audit_logs(request): """导出审计日志(CSV格式)""" try: # 获取查询参数 - action = request.GET.get('action') - user_id = request.GET.get('user_id') - host_id = request.GET.get('host_id') - start_date = request.GET.get('start_date') - end_date = request.GET.get('end_date') + action = request.GET.get('action', '')[:50] if request.GET.get('action') else None + user_id = _validate_int_param(request.GET.get('user_id'), default=None, min_val=1) if request.GET.get('user_id') else None + host_id = _validate_int_param(request.GET.get('host_id'), default=None, min_val=1) if request.GET.get('host_id') else None + start_date = _validate_date_param(request.GET.get('start_date')) + end_date = _validate_date_param(request.GET.get('end_date')) # 构建查询集 queryset = AuditLog.objects.select_related('user', 'host').all() diff --git a/apps/bootstrap/management/commands/cleanup_expired_sessions.py b/apps/bootstrap/management/commands/cleanup_expired_sessions.py index 840bfec..9b9c12c 100644 --- a/apps/bootstrap/management/commands/cleanup_expired_sessions.py +++ b/apps/bootstrap/management/commands/cleanup_expired_sessions.py @@ -1,47 +1,73 @@ from django.core.management.base import BaseCommand from django.utils import timezone -from apps.bootstrap.models import ActiveSession -from datetime import timedelta +from apps.bootstrap.models import ActiveSession, InitialToken class Command(BaseCommand): - help = '清理过期的活动会话' + help = '清理过期的活动会话和初始令牌' def add_arguments(self, parser): parser.add_argument( '--dry-run', action='store_true', - help='仅显示将要删除的会话,不实际删除', + help='仅显示将要删除的记录,不实际删除', ) def handle(self, *args, **options): dry_run = options['dry_run'] - - # 查找过期的会话 - expired_sessions = ActiveSession.objects.filter(expires_at__lt=timezone.now()) - + now = timezone.now() + + expired_sessions = ActiveSession.objects.filter( + expires_at__lt=now, + ) if expired_sessions.exists(): + count = expired_sessions.count() self.stdout.write( - self.style.SUCCESS( - f'找到 {expired_sessions.count()} 个过期的会话' + f'找到 {count} 个过期的会话' + ) + if not dry_run: + expired_sessions.delete() + self.stdout.write( + self.style.SUCCESS( + f'已删除 {count} 个过期的会话' + ) ) + + expired_tokens = InitialToken.objects.filter( + expires_at__lt=now, + ) + if expired_tokens.exists(): + count = expired_tokens.count() + self.stdout.write( + f'找到 {count} 个过期的初始令牌' ) - - if dry_run: - for session in expired_sessions: - self.stdout.write( - f"- Session: {session.session_token[:12]}..., " - f"Host: {session.host.name}, " - f"Expired: {session.expires_at}" + if not dry_run: + expired_tokens.delete() + self.stdout.write( + self.style.SUCCESS( + f'已删除 {count} 个过期的初始令牌' ) - else: - deleted_count = expired_sessions.delete()[0] + ) + + orphan_tokens = InitialToken.objects.filter( + host=None, + status='ISSUED', + expires_at__gt=now, + ) + if orphan_tokens.exists(): + count = orphan_tokens.count() + self.stdout.write( + f'找到 {count} 个未关联主机的初始令牌' + ) + if not dry_run: + orphan_tokens.delete() self.stdout.write( self.style.SUCCESS( - f'成功删除 {deleted_count} 个过期的会话' + f'已删除 {count} 个未关联主机的初始令牌' ) ) - else: + + if not any([expired_sessions.exists(), expired_tokens.exists(), orphan_tokens.exists()]): self.stdout.write( - self.style.SUCCESS('没有找到过期的会话') + self.style.SUCCESS('没有需要清理的记录') ) \ No newline at end of file diff --git a/apps/bootstrap/middleware.py b/apps/bootstrap/middleware.py index 47b510c..e99c6ff 100644 --- a/apps/bootstrap/middleware.py +++ b/apps/bootstrap/middleware.py @@ -28,14 +28,18 @@ def __call__(self, request): '/api/exchange_token', '/api/exchange_token/', '/bootstrap/exchange-token/', - '/api/get_session_token', # 允许InitialToken访问(无斜杠版本) - '/api/get_session_token/', # 允许InitialToken访问(有斜杠版本) - '/bootstrap/api/get_session_token', # Bootstrap应用下的路径(无斜杠) - '/bootstrap/api/get_session_token/', # Bootstrap应用下的路径(有斜杠) - '/api/check_totp_status', # 允许InitialToken访问检查状态(无斜杠版本) - '/api/check_totp_status/', # 允许InitialToken访问检查状态(有斜杠版本) - '/bootstrap/api/check_totp_status', # Bootstrap应用下的检查状态路径(无斜杠) - '/bootstrap/api/check_totp_status/' # Bootstrap应用下的检查状态路径(有斜杠) + '/api/get_session_token', + '/api/get_session_token/', + '/bootstrap/api/get_session_token', + '/bootstrap/api/get_session_token/', + '/api/check_totp_status', + '/api/check_totp_status/', + '/bootstrap/api/check_totp_status', + '/bootstrap/api/check_totp_status/', + '/bootstrap/sse/init-status', + '/bootstrap/sse/init-status/', + '/bootstrap/api/upload_host_cert', + '/bootstrap/api/upload_host_cert/', ] if (request.path.startswith('/api/') or diff --git a/apps/bootstrap/migrations/0009_initialtoken_host_nullable.py b/apps/bootstrap/migrations/0009_initialtoken_host_nullable.py new file mode 100644 index 0000000..ca11b15 --- /dev/null +++ b/apps/bootstrap/migrations/0009_initialtoken_host_nullable.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.30 on 2026-05-23 09:29 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('hosts', '0011_host_auth_method_host_cert_key_path_and_more'), + ('bootstrap', '0008_add_pairing_attempts'), + ] + + operations = [ + migrations.AlterField( + model_name='initialtoken', + name='host', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='hosts.host', verbose_name='关联的主机'), + ), + ] diff --git a/apps/bootstrap/migrations/0010_add_cert_data.py b/apps/bootstrap/migrations/0010_add_cert_data.py new file mode 100644 index 0000000..27a0b8b --- /dev/null +++ b/apps/bootstrap/migrations/0010_add_cert_data.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.30 on 2026-05-23 14:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bootstrap', '0009_initialtoken_host_nullable'), + ] + + operations = [ + migrations.AddField( + model_name='initialtoken', + name='cert_data', + field=models.JSONField(blank=True, default=None, null=True, verbose_name='暂存证书数据'), + ), + ] diff --git a/apps/bootstrap/migrations/0011_add_cert_provision_token.py b/apps/bootstrap/migrations/0011_add_cert_provision_token.py new file mode 100644 index 0000000..e7e33e4 --- /dev/null +++ b/apps/bootstrap/migrations/0011_add_cert_provision_token.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.30 on 2026-05-29 15:57 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('hosts', '0012_host_username_optional'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('bootstrap', '0010_add_cert_data'), + ] + + operations = [ + migrations.CreateModel( + name='CertProvisionToken', + fields=[ + ('token', models.CharField(max_length=64, primary_key=True, serialize=False, verbose_name='配置令牌')), + ('server_host', models.CharField(max_length=255, verbose_name='服务器地址')), + ('expires_at', models.DateTimeField(verbose_name='过期时间')), + ('status', models.CharField(choices=[('ISSUED', '已签发'), ('HOSTNAME_UPLOADED', '主机名已上传'), ('CERT_ISSUED', '证书已签发'), ('HOST_CONFIGURED', '主机已配置'), ('CONSUMED', '已消耗')], default='ISSUED', max_length=20, verbose_name='状态')), + ('consumed_at', models.DateTimeField(blank=True, null=True, verbose_name='消耗时间')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='创建者')), + ('host', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='hosts.host', verbose_name='关联的主机')), + ], + options={ + 'verbose_name': '证书配置令牌', + 'verbose_name_plural': '证书配置令牌', + 'db_table': 'cert_provision_token', + }, + ), + ] diff --git a/apps/bootstrap/migrations/0012_certprovisiontoken_cert_data_and_more.py b/apps/bootstrap/migrations/0012_certprovisiontoken_cert_data_and_more.py new file mode 100644 index 0000000..62d73eb --- /dev/null +++ b/apps/bootstrap/migrations/0012_certprovisiontoken_cert_data_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.30 on 2026-05-30 00:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bootstrap', '0011_add_cert_provision_token'), + ] + + operations = [ + migrations.AddField( + model_name='certprovisiontoken', + name='cert_data', + field=models.JSONField(blank=True, default=None, null=True, verbose_name='暂存证书数据'), + ), + migrations.AddField( + model_name='certprovisiontoken', + name='hostname', + field=models.CharField(blank=True, default='', max_length=255, verbose_name='主机名'), + ), + ] diff --git a/apps/bootstrap/migrations/0013_certprovisiontoken_ip_address.py b/apps/bootstrap/migrations/0013_certprovisiontoken_ip_address.py new file mode 100644 index 0000000..de5339d --- /dev/null +++ b/apps/bootstrap/migrations/0013_certprovisiontoken_ip_address.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.30 on 2026-05-30 02:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bootstrap', '0012_certprovisiontoken_cert_data_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='certprovisiontoken', + name='ip_address', + field=models.CharField(blank=True, default='', max_length=255, verbose_name='主机IP地址'), + ), + ] diff --git a/apps/bootstrap/models.py b/apps/bootstrap/models.py index 68d6f1a..aaa9636 100644 --- a/apps/bootstrap/models.py +++ b/apps/bootstrap/models.py @@ -18,13 +18,14 @@ class InitialToken(models.Model): MAX_PAIRING_ATTEMPTS = 5 token = models.CharField(max_length=255, primary_key=True, verbose_name="AccessToken") - host = models.ForeignKey(Host, on_delete=models.CASCADE, verbose_name="关联的主机") + host = models.ForeignKey(Host, on_delete=models.CASCADE, verbose_name="关联的主机", null=True, blank=True) expires_at = models.DateTimeField(verbose_name="AccessToken过期时间") status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='ISSUED', verbose_name="状态") pairing_code = models.CharField(max_length=6, verbose_name="配对码", blank=True, null=True) pairing_code_expires_at = models.DateTimeField(verbose_name="配对码过期时间", blank=True, null=True) pairing_attempts = models.IntegerField(default=0, verbose_name="配对码验证尝试次数") created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + cert_data = models.JSONField(verbose_name="暂存证书数据", blank=True, null=True, default=None) class Meta: verbose_name = "初始令牌" @@ -37,7 +38,7 @@ def generate_pairing_code(self): self.pairing_code = code self.pairing_code_expires_at = timezone.now() + timedelta(minutes=5) self.pairing_attempts = 0 - self.save() + self.save(update_fields=['pairing_code', 'pairing_code_expires_at', 'pairing_attempts']) return code def verify_pairing_code(self, input_code): @@ -48,23 +49,26 @@ def verify_pairing_code(self, input_code): if timezone.now() > self.pairing_code_expires_at: return False + from django.db.models import F + InitialToken.objects.filter(pk=self.pk).update( + pairing_attempts=F('pairing_attempts') + 1 + ) + self.refresh_from_db() + if self.pairing_attempts >= self.MAX_PAIRING_ATTEMPTS: self.pairing_code = None self.pairing_code_expires_at = None - self.save() + self.save(update_fields=['pairing_code', 'pairing_code_expires_at']) return False - self.pairing_attempts += 1 - if self.pairing_code != input_code: - self.save(update_fields=['pairing_attempts']) return False self.status = 'PAIRED' self.pairing_code = None self.pairing_code_expires_at = None self.pairing_attempts = 0 - self.save() + self.save(update_fields=['status', 'pairing_code', 'pairing_code_expires_at', 'pairing_attempts']) return True @@ -79,4 +83,37 @@ class ActiveSession(models.Model): class Meta: verbose_name = "活动会话" verbose_name_plural = "活动会话" - db_table = "active_session" \ No newline at end of file + db_table = "active_session" + + +class CertProvisionToken(models.Model): + STATUS_CHOICES = [ + ('ISSUED', '已签发'), + ('HOSTNAME_UPLOADED', '主机名已上传'), + ('CERT_ISSUED', '证书已签发'), + ('HOST_CONFIGURED', '主机已配置'), + ('CONSUMED', '已消耗'), + ] + + token = models.CharField(max_length=64, primary_key=True, verbose_name="配置令牌") + host = models.ForeignKey(Host, on_delete=models.CASCADE, verbose_name="关联的主机", null=True, blank=True) + server_host = models.CharField(max_length=255, verbose_name="服务器地址") + hostname = models.CharField(max_length=255, verbose_name="主机名", blank=True, default='') + ip_address = models.CharField(max_length=255, verbose_name="主机IP地址", blank=True, default='') + expires_at = models.DateTimeField(verbose_name="过期时间") + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='ISSUED', verbose_name="状态") + cert_data = models.JSONField(verbose_name="暂存证书数据", blank=True, null=True, default=None) + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="创建者") + consumed_at = models.DateTimeField(null=True, blank=True, verbose_name="消耗时间") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + + class Meta: + verbose_name = "证书配置令牌" + verbose_name_plural = "证书配置令牌" + db_table = "cert_provision_token" + + def is_expired(self): + return timezone.now() > self.expires_at + + def is_valid(self): + return self.status == 'ISSUED' and not self.is_expired() \ No newline at end of file diff --git a/apps/bootstrap/tasks.py b/apps/bootstrap/tasks.py index 8bf00a9..6ff60ff 100644 --- a/apps/bootstrap/tasks.py +++ b/apps/bootstrap/tasks.py @@ -1,20 +1,28 @@ +import base64 +import datetime +import logging +from datetime import timedelta +from typing import cast + from celery import shared_task +from cryptography import x509 +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec from django.utils import timezone + from .models import ActiveSession, InitialToken -from datetime import timedelta -import logging logger = logging.getLogger(__name__) @shared_task def cleanup_expired_sessions(): - """清理过期的活动会话""" try: - expired_sessions = ActiveSession.objects.filter(expires_at__lt=timezone.now()) + expired_sessions = ActiveSession.objects.filter( + expires_at__lt=timezone.now() + ) count = expired_sessions.count() expired_sessions.delete() - logger.info(f"清理了 {count} 个过期的活动会话") return f"清理了 {count} 个过期的活动会话" except Exception as e: @@ -24,16 +32,13 @@ def cleanup_expired_sessions(): @shared_task def cleanup_expired_initial_tokens(): - """清理过期的初始令牌""" try: - # 删除已过期且已消耗的令牌,或者过期超过7天的令牌 cutoff_time = timezone.now() - timedelta(days=7) expired_tokens = InitialToken.objects.filter( expires_at__lt=cutoff_time ) count = expired_tokens.count() expired_tokens.delete() - logger.info(f"清理了 {count} 个过期的初始令牌") return f"清理了 {count} 个过期的初始令牌" except Exception as e: @@ -43,46 +48,217 @@ def cleanup_expired_initial_tokens(): @shared_task def generate_bootstrap_config(hostname, ip_address, operator_id): - """生成引导配置 - 模拟函数""" try: - # 这里应该是实际的引导配置生成逻辑 - # 模拟返回一些配置信息 config = { 'hostname': hostname, 'ip_address': ip_address, 'generated_at': timezone.now().isoformat(), - 'status': 'success' - } - - return { - 'success': True, - 'config': config + 'status': 'success', } + return {'success': True, 'config': config} except Exception as e: logger.error(f"生成引导配置时出错: {str(e)}") - return { - 'success': False, - 'error': str(e) - } + return {'success': False, 'error': str(e)} @shared_task def initialize_host_bootstrap(host_id, operator_id): - """初始化主机引导 - 模拟函数""" try: - # 这里应该是实际的主机引导初始化逻辑 from apps.hosts.models import Host host = Host.objects.get(id=host_id) - - # 模拟引导过程 - result = { + return { 'host_id': host_id, 'hostname': host.hostname, 'status': 'completed', - 'completed_at': timezone.now().isoformat() + 'completed_at': timezone.now().isoformat(), } - - return result except Exception as e: logger.error(f"初始化主机引导时出错: {str(e)}") raise + + +@shared_task(bind=True, max_retries=1) +def cert_provision_issue_certs(self, token_str): + from apps.bootstrap.models import CertProvisionToken + from apps.certificates.models import CertificateAuthority + from utils.cert_service import ( + generate_ca, issue_server_cert, issue_client_cert, + generate_random_username, generate_random_password, + ) + from utils.cert_storage import generate_cert_paths, save_cert_files + + try: + provision_token = CertProvisionToken.objects.get(token=token_str) + except CertProvisionToken.DoesNotExist: + return + + if provision_token.status != 'HOSTNAME_UPLOADED': + return + + host = provision_token.host + hostname = host.hostname if host else provision_token.hostname + if not hostname: + return + + ip_address = provision_token.ip_address or '' + + ca_obj = CertificateAuthority.objects.filter(is_active=True).first() + if not ca_obj: + ca_key, ca_cert = generate_ca() + ca_obj = CertificateAuthority( + name='WinRM-CA', is_active=True, + ) + ca_obj.private_key = ca_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode('utf-8') + ca_obj.certificate = ca_cert.public_bytes( + serialization.Encoding.PEM, + ).decode('utf-8') + ca_obj.expires_at = ( + datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(days=3650) + ) + ca_obj.save() + + if not ca_obj.private_key or not ca_obj.certificate: + return + + ca_key = cast( + ec.EllipticCurvePrivateKey, + serialization.load_pem_private_key( + ca_obj.private_key.encode(), password=None, + ), + ) + ca_cert = x509.load_pem_x509_certificate(ca_obj.certificate.encode()) + + ntlm_user = generate_random_username() + ntlm_password = generate_random_password() + upn_value = f"{ntlm_user}@localhost" + + server_result = issue_server_cert( + ca_key=ca_key, + ca_cert=ca_cert, + hostname=hostname, + ip_address=ip_address or None, + ) + + client_key, client_cert = issue_client_cert( + ca_key=ca_key, + ca_cert=ca_cert, + upn_value=upn_value, + ) + + cert_root, cert_sub = generate_cert_paths() + + ca_cert_pem = ca_cert.public_bytes(serialization.Encoding.PEM) + client_cert_pem = client_cert.public_bytes(serialization.Encoding.PEM) + client_key_pem = client_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + cert_dir = save_cert_files( + cert_root=cert_root, + cert_sub=cert_sub, + ca_cert_pem=ca_cert_pem, + client_cert_pem=client_cert_pem, + server_pfx_bytes=server_result['pfx_data'], + client_key_pem=client_key_pem, + ) + + if host: + host.cert_root = cert_root + host.cert_sub = cert_sub + host.pfx_password = server_result['pfx_password'] + host.ntlm_fallback_user = ntlm_user + host.ntlm_fallback_password = ntlm_password + host.cert_provision_status = 'ready' + host.cert_pem_path = str(cert_dir / 'client.crt') + host.cert_key_path = str(cert_dir / 'client.key') + host.auth_method = 'certificate' + host.use_ssl = True + if host.port == 5985: + host.port = 5986 + host.save() + + if not host: + provision_token.cert_data = { + 'cert_root': cert_root, + 'cert_sub': cert_sub, + 'pfx_password': server_result['pfx_password'], + 'ntlm_user': ntlm_user, + 'ntlm_password': ntlm_password, + 'ca_cert_b64': base64.b64encode( + ca_cert_pem + ).decode('utf-8'), + 'client_cert_b64': base64.b64encode( + client_cert_pem + ).decode('utf-8'), + 'server_pfx_b64': base64.b64encode( + server_result['pfx_data'] + ).decode('utf-8'), + } + + provision_token.status = 'CERT_ISSUED' + provision_token.save() + + return {'success': True, 'host_id': host.pk if host else None} + + +@shared_task +def cleanup_expired_provision_tokens(): + from apps.bootstrap.models import CertProvisionToken + now = timezone.now() + CertProvisionToken.objects.filter( + status='ISSUED', expires_at__lt=now, + ).delete() + week_ago = now - timedelta(days=7) + CertProvisionToken.objects.filter(expires_at__lt=week_ago).delete() + + +@shared_task +def cleanup_unactivated_certificates(): + from apps.hosts.models import Host + from utils.cert_storage import delete_cert_files + now = timezone.now() + cutoff = now - timedelta(minutes=60) + hosts = Host.objects.filter( + cert_provision_status__in=['pending', 'ready'], + created_at__lt=cutoff, + cert_activated_at__isnull=True, + ) + for host in hosts: + if host.cert_root and host.cert_sub: + delete_cert_files(host.cert_root, host.cert_sub) + host.cert_provision_status = 'failed' + host.cert_root = '' + host.cert_sub = '' + host.save() + + +@shared_task +def cleanup_orphan_cert_dirs(): + from apps.hosts.models import Host + from utils.cert_storage import get_cert_base_dir + import shutil + base_dir = get_cert_base_dir() + if not base_dir.exists(): + return + active_paths = set() + for host in Host.objects.filter(cert_root__gt='', cert_sub__gt=''): + active_paths.add((host.cert_root, host.cert_sub)) + for root_dir in base_dir.iterdir(): + if root_dir.is_dir() and len(root_dir.name) == 2: + for sub_dir in root_dir.iterdir(): + if sub_dir.is_dir() and len(sub_dir.name) == 2: + if (root_dir.name, sub_dir.name) not in active_paths: + shutil.rmtree(sub_dir, ignore_errors=True) + try: + root_dir.rmdir() + except OSError: + logger.debug( + "Skipping removal of non-empty or inaccessible orphan cert root dir: %s", + root_dir, + ) diff --git a/apps/bootstrap/token_utils.py b/apps/bootstrap/token_utils.py new file mode 100644 index 0000000..6a967ff --- /dev/null +++ b/apps/bootstrap/token_utils.py @@ -0,0 +1,21 @@ +import base64 +import json + + +def encode_provision_token(raw_token: str, scheme: str, host: str) -> str: + payload = json.dumps({"t": raw_token, "s": scheme, "h": host}, separators=(',', ':')) + return base64.urlsafe_b64encode(payload.encode('utf-8')).decode('ascii') + + +def decode_provision_token(encoded: str) -> dict | None: + try: + padding = 4 - len(encoded) % 4 + if padding != 4: + encoded += '=' * padding + payload = base64.urlsafe_b64decode(encoded.encode('ascii')).decode('utf-8') + data = json.loads(payload) + if 't' in data and 's' in data and 'h' in data: + return data + except Exception: + pass + return None diff --git a/apps/bootstrap/urls.py b/apps/bootstrap/urls.py index 0c1b6a1..b18bd70 100644 --- a/apps/bootstrap/urls.py +++ b/apps/bootstrap/urls.py @@ -24,7 +24,8 @@ path('api/verify_pairing_code/', views.verify_pairing_code, name='api_verify_pairing_code'), path('api/exchange_token/', views.exchange_token, name='api_exchange_token'), path('api/get_session_token', views.get_session_token, name='api_get_session_token_no_slash'), # 不带斜杠版本 - path('api/get_session_token/', views.get_session_token, name='api_get_session_token'), # 带斜杠版本 + path('api/get_session_token/', views.get_session_token, name='api_get_session_token'), + path('api/upload_host_cert/', views.upload_host_cert, name='api_upload_host_cert'), path('api/check_pairing_status', views.check_pairing_status, name='api_check_pairing_status'), # 新增:检查配对状态 path('api/session/', views.revoke_session, name='api_revoke_session'), @@ -33,4 +34,15 @@ path('api/complete-auto-register/', views.complete_auto_register, name='complete_auto_register'), path('api/pending-hosts/', views.get_pending_hosts, name='get_pending_hosts'), path('api/revoke-pending-host/', views.revoke_pending_host, name='revoke_pending_host'), + + path('sse/init-status/', views.sse_init_status, name='sse_init_status'), + + path('api/cert-provision/validate/', views.cert_provision_validate, name='cert_provision_validate'), + path('api/cert-provision/upload-hostname/', views.cert_provision_upload_hostname, name='cert_provision_upload_hostname'), + path('api/cert-provision/download-certs/', views.cert_provision_download_certs, name='cert_provision_download_certs'), + path('api/cert-provision/notify-complete/', views.cert_provision_notify_complete, name='cert_provision_notify_complete'), + path('api/cert-provision/disable-password-auth/', views.cert_provision_disable_password_auth, name='cert_provision_disable_password_auth'), + path('api/cert-provision/test-result/', views.cert_provision_test_result, name='cert_provision_test_result'), + path('api/cert-provision/status-stream/', views.cert_provision_status_stream, name='cert_provision_status_stream'), + path('api/cert-provision/test-stream/', views.cert_provision_test_stream, name='cert_provision_test_stream'), ] \ No newline at end of file diff --git a/apps/bootstrap/views.py b/apps/bootstrap/views.py index d17a11b..8259db3 100644 --- a/apps/bootstrap/views.py +++ b/apps/bootstrap/views.py @@ -1,10 +1,10 @@ -from django.http import JsonResponse +from django.http import JsonResponse, StreamingHttpResponse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from django.contrib.auth.decorators import login_required, permission_required from django.utils.decorators import method_decorator from django.views import View -from .models import InitialToken, ActiveSession +from .models import InitialToken, ActiveSession, CertProvisionToken from apps.hosts.models import Host from apps.certificates.models import CertificateAuthority, ServerCertificate from apps.tasks.models import AsyncTask @@ -12,6 +12,7 @@ from django.shortcuts import get_object_or_404 import json import logging +import base64 from django.utils import timezone from django.core.cache import cache import secrets @@ -49,7 +50,100 @@ def wrapper(request, *args, **kwargs): return decorator +def _save_cert_to_host(host, pfx_b64, pfx_password, service_user, service_password): + import base64 + from cryptography.hazmat.primitives.serialization import pkcs12, Encoding, PrivateFormat, NoEncryption + + try: + pfx_data = base64.b64decode(pfx_b64) + private_key, certificate, _ = pkcs12.load_key_and_certificates( + pfx_data, pfx_password.encode() + ) + except Exception as e: + logger.error(f"PFX decode failed for host {host.pk}: {e}") + return False + + if not private_key or not certificate: + return False + + import os + from django.conf import settings + cert_dir = os.path.join(settings.MEDIA_ROOT, 'certs', 'hosts', str(host.pk)) + os.makedirs(cert_dir, exist_ok=True) + + pem_path = os.path.join(cert_dir, 'client.pem') + key_path = os.path.join(cert_dir, 'client.key') + + with open(pem_path, 'wb') as f: + f.write(certificate.public_bytes(Encoding.PEM)) + with open(key_path, 'wb') as f: + f.write(private_key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())) + + os.chmod(pem_path, 0o600) + os.chmod(key_path, 0o600) + + update_fields = {'cert_pem_path': pem_path, 'cert_key_path': key_path} + if service_user and service_password: + update_fields['username'] = service_user + from utils.crypto import encrypt_value + update_fields['_password'] = encrypt_value(service_password) + + from apps.hosts.models import Host + Host.objects.filter(pk=host.pk).update(**update_fields) + + try: + host.refresh_from_db() + host.test_connection() + except Exception as e: + logger.warning(f"Connection test after cert upload failed: {e}") + + return True + + +@csrf_exempt @require_http_methods(["POST"]) +def upload_host_cert(request): + try: + data = json.loads(request.body) + token_value = data.get('token', '') + pfx_b64 = data.get('pfx_b64', '') + pfx_password = data.get('pfx_password', '') + service_user = data.get('service_user', '') + service_password = data.get('service_password', '') + + if not token_value or not pfx_b64: + return JsonResponse({'success': False, 'error': 'Missing required fields'}, status=400) + + try: + token_obj = InitialToken.objects.get(token=token_value) + except InitialToken.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Invalid token'}, status=401) + + if token_obj.host: + ok = _save_cert_to_host( + token_obj.host, pfx_b64, pfx_password, + service_user, service_password, + ) + if not ok: + return JsonResponse({'success': False, 'error': 'Invalid PFX data'}, status=400) + else: + token_obj.cert_data = { + 'pfx_b64': pfx_b64, + 'pfx_password': pfx_password, + 'service_user': service_user, + 'service_password': service_password, + } + token_obj.save(update_fields=['cert_data']) + logger.info(f"Cert data stored on token {token_obj.pk[:8]}, waiting for host association") + + logger.info(f"Cert uploaded for token {token_obj.pk[:8]}") + return JsonResponse({'success': True}) + + except Exception as e: + logger.error(f"upload_host_cert error: {e}", exc_info=True) + return JsonResponse({'success': False, 'error': 'Internal server error'}, status=500) + + @login_required @permission_required('hosts.delete_host', raise_exception=True) def revoke_pending_host(request): @@ -609,18 +703,16 @@ def get_session_token(request): # 原子操作:生成新的session_token,创建ActiveSession记录 from django.db import transaction with transaction.atomic(): - # 生成新的session_token session_token = str(uuid.uuid4()) - - # 在ActiveSession表中插入记录 - active_session = ActiveSession.objects.create( - session_token=session_token, - host=token_obj.host, - bound_ip=ip, - expires_at=timezone.now() + timezone.timedelta(hours=1) # 1小时后过期 - ) - - # 更新InitialToken状态为CONSUMED + + if token_obj.host: + ActiveSession.objects.create( + session_token=session_token, + host=token_obj.host, + bound_ip=ip, + expires_at=timezone.now() + timezone.timedelta(hours=1) + ) + token_obj.status = 'CONSUMED' token_obj.save() @@ -941,6 +1033,73 @@ def auto_register_host(request): }, status=500) +@login_required +def sse_init_status(request): + import json as _json + + token = request.GET.get('token', '') + if not token: + return JsonResponse( + {'error': 'token required'}, status=400, + ) + + def event_stream(): + for _ in range(120): + try: + token_obj = InitialToken.objects.get(token=token) + status = token_obj.status + data = { + 'status': status, + 'host_id': ( + token_obj.host_id + if token_obj.host_id else None + ), + 'cert_uploaded': bool(token_obj.cert_data), + } + if status == 'CONSUMED' and token_obj.host: + host_status = Host.objects.filter( + pk=token_obj.host_id + ).values_list('status', flat=True).first() + data['host_status'] = host_status + if host_status == 'online': + yield f"data: {_json.dumps(data)}\n\n" + return + if status == 'CONSUMED' and token_obj.cert_data and not token_obj.host: + yield f"data: {_json.dumps(data)}\n\n" + return + yield f"data: {_json.dumps(data)}\n\n" + if status == 'CONSUMED': + for _ in range(24): + time.sleep(5) + token_obj.refresh_from_db() + if token_obj.cert_data and not token_obj.host: + data['cert_uploaded'] = True + yield f"data: {_json.dumps(data)}\n\n" + return + if token_obj.host: + host_status = Host.objects.filter( + pk=token_obj.host_id + ).values_list('status', flat=True).first() + data['host_status'] = host_status + if host_status == 'online': + yield f"data: {_json.dumps(data)}\n\n" + return + return + except InitialToken.DoesNotExist: + yield f"data: {_json.dumps({'status': 'NOT_FOUND'})}\n\n" + return + time.sleep(5) + yield f"data: {_json.dumps({'status': 'TIMEOUT'})}\n\n" + + response = StreamingHttpResponse( + event_stream(), + content_type='text/event-stream', + ) + response['Cache-Control'] = 'no-cache' + response['X-Accel-Buffering'] = 'no' + return response + + def get_client_ip(request): """获取客户端真实IP地址""" from django.conf import settings as django_settings @@ -1048,4 +1207,237 @@ def delete(self, request): return JsonResponse({ 'success': False, 'error': 'Failed to delete initial token' - }, status=500) \ No newline at end of file + }, status=500) + + +@csrf_exempt +@require_http_methods(["GET"]) +def cert_provision_validate(request): + token_str = request.GET.get('token', '') + try: + provision_token = CertProvisionToken.objects.get(token=token_str) + return JsonResponse({ + 'valid': provision_token.is_valid(), + 'server_host': provision_token.server_host, + 'status': provision_token.status, + }) + except CertProvisionToken.DoesNotExist: + return JsonResponse({'valid': False, 'server_host': '', 'status': ''}) + + +@csrf_exempt +@require_http_methods(["POST"]) +def cert_provision_upload_hostname(request): + try: + data = json.loads(request.body) + token_str = data.get('token', '') + hostname = data.get('hostname', '') + except (json.JSONDecodeError, AttributeError): + return JsonResponse({'success': False, 'error': 'Invalid request'}, status=400) + + try: + provision_token = CertProvisionToken.objects.get(token=token_str) + except CertProvisionToken.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Token not found'}, status=404) + + if not provision_token.is_valid(): + return JsonResponse({'success': False, 'error': 'Token expired'}, status=403) + + host = provision_token.host + if host: + Host.objects.filter(pk=host.pk).update(hostname=hostname) + host.refresh_from_db() + else: + provision_token.hostname = hostname + + provision_token.status = 'HOSTNAME_UPLOADED' + provision_token.save() + + from apps.bootstrap.tasks import cert_provision_issue_certs + cert_provision_issue_certs.delay(token_str) + + return JsonResponse({'success': True}) + + +@csrf_exempt +@require_http_methods(["GET"]) +def cert_provision_download_certs(request): + token_str = request.GET.get('token', '') + try: + provision_token = CertProvisionToken.objects.get(token=token_str) + except CertProvisionToken.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Token not found'}, status=404) + + if provision_token.status != 'CERT_ISSUED': + return JsonResponse({'success': False, 'error': 'Certificates not ready'}, status=400) + + host = provision_token.host + if host and host.cert_root and host.cert_sub: + from utils.cert_storage import get_cert_file_paths + paths = get_cert_file_paths(host.cert_root, host.cert_sub) + + ca_cert_b64 = base64.b64encode(paths['ca_cert'].read_bytes()).decode('utf-8') + client_cert_b64 = base64.b64encode(paths['client_cert'].read_bytes()).decode('utf-8') + server_pfx_b64 = base64.b64encode(paths['server_pfx'].read_bytes()).decode('utf-8') + + return JsonResponse({ + 'success': True, + 'ca_cert': ca_cert_b64, + 'client_cert': client_cert_b64, + 'server_pfx': server_pfx_b64, + 'pfx_password': host.pfx_password, + 'ntlm_user': host.ntlm_fallback_user, + 'ntlm_password': host.ntlm_fallback_password, + 'upn_value': f"{host.ntlm_fallback_user}@localhost", + }) + elif provision_token.cert_data: + cd = provision_token.cert_data + return JsonResponse({ + 'success': True, + 'ca_cert': cd.get('ca_cert_b64', ''), + 'client_cert': cd.get('client_cert_b64', ''), + 'server_pfx': cd.get('server_pfx_b64', ''), + 'pfx_password': cd.get('pfx_password', ''), + 'ntlm_user': cd.get('ntlm_user', ''), + 'ntlm_password': cd.get('ntlm_password', ''), + 'upn_value': f"{cd.get('ntlm_user', '')}@localhost", + }) + + return JsonResponse({'success': False, 'error': 'Host not configured'}, status=400) + + +@csrf_exempt +@require_http_methods(["POST"]) +def cert_provision_notify_complete(request): + try: + data = json.loads(request.body) + token_str = data.get('token', '') + except (json.JSONDecodeError, AttributeError): + return JsonResponse({'success': False, 'error': 'Invalid request'}, status=400) + + try: + provision_token = CertProvisionToken.objects.get(token=token_str) + except CertProvisionToken.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Token not found'}, status=404) + + provision_token.status = 'HOST_CONFIGURED' + provision_token.save() + + host = provision_token.host + if host: + from apps.hosts.tasks import test_winrm_connection + use_cert = host.auth_method == 'certificate' + test_winrm_connection.delay(host.pk, use_certificate_auth=use_cert) + return JsonResponse({'success': True, 'test': 'started'}) + else: + return JsonResponse({'success': True, 'test': 'deferred'}) + + +@csrf_exempt +@require_http_methods(["POST"]) +def cert_provision_disable_password_auth(request): + try: + data = json.loads(request.body) + token_str = data.get('token', '') + except (json.JSONDecodeError, AttributeError): + return JsonResponse({'success': False, 'error': 'Invalid request'}, status=400) + + try: + provision_token = CertProvisionToken.objects.get(token=token_str) + except CertProvisionToken.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Token not found'}, status=404) + + provision_token.status = 'CONSUMED' + provision_token.consumed_at = timezone.now() + provision_token.save() + + return JsonResponse({'success': True}) + + +@csrf_exempt +@require_http_methods(["GET"]) +def cert_provision_test_result(request): + token_str = request.GET.get('token', '') + try: + provision_token = CertProvisionToken.objects.get(token=token_str) + except CertProvisionToken.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Token not found'}, status=404) + + host = provision_token.host + if not host: + return JsonResponse({'status': 'testing'}) + + if host.cert_provision_status == 'configured': + return JsonResponse({'status': 'success'}) + elif host.cert_provision_status == 'failed': + return JsonResponse({'status': 'failed', 'error': 'Connection test failed'}) + else: + return JsonResponse({'status': 'testing'}) + + +@csrf_exempt +@require_http_methods(["GET"]) +def cert_provision_status_stream(request): + token_str = request.GET.get('token', '') + + def event_stream(): + for _ in range(120): + try: + provision_token = CertProvisionToken.objects.get(token=token_str) + except CertProvisionToken.DoesNotExist: + yield f"data: {json.dumps({'status': 'failed', 'error': 'Token not found'})}\n\n" + return + + if provision_token.status == 'CERT_ISSUED': + host = provision_token.host + yield f"data: {json.dumps({'status': 'ready'})}\n\n" + return + elif provision_token.status == 'HOST_CONFIGURED': + yield f"data: {json.dumps({'status': 'configured'})}\n\n" + return + elif provision_token.is_expired(): + yield f"data: {json.dumps({'status': 'failed', 'error': 'Token expired'})}\n\n" + return + + yield f"data: {json.dumps({'status': 'pending'})}\n\n" + time.sleep(5) + + yield f"data: {json.dumps({'status': 'failed', 'error': 'Timeout'})}\n\n" + + response = StreamingHttpResponse(event_stream(), content_type='text/event-stream') + response['Cache-Control'] = 'no-cache' + response['X-Accel-Buffering'] = 'no' + return response + + +@csrf_exempt +@require_http_methods(["GET"]) +def cert_provision_test_stream(request): + token_str = request.GET.get('token', '') + + def event_stream(): + for _ in range(60): + try: + provision_token = CertProvisionToken.objects.get(token=token_str) + except CertProvisionToken.DoesNotExist: + yield f"data: {json.dumps({'status': 'failed', 'error': 'Token not found'})}\n\n" + return + + host = provision_token.host + if host: + if host.cert_provision_status == 'configured': + yield f"data: {json.dumps({'status': 'success'})}\n\n" + return + elif host.cert_provision_status == 'failed': + yield f"data: {json.dumps({'status': 'failed', 'error': 'Connection test failed'})}\n\n" + return + + yield f"data: {json.dumps({'status': 'testing'})}\n\n" + time.sleep(5) + + yield f"data: {json.dumps({'status': 'failed', 'error': 'Timeout'})}\n\n" + + response = StreamingHttpResponse(event_stream(), content_type='text/event-stream') + response['Cache-Control'] = 'no-cache' + response['X-Accel-Buffering'] = 'no' + return response \ No newline at end of file diff --git a/apps/certificates/apps.py b/apps/certificates/apps.py index 75bc569..4c9ea59 100755 --- a/apps/certificates/apps.py +++ b/apps/certificates/apps.py @@ -1,11 +1,51 @@ +import logging + from django.apps import AppConfig +logger = logging.getLogger(__name__) + + class CertificatesConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'apps.certificates' verbose_name = '证书管理系统' def ready(self): - # 导入信号处理器 - import apps.certificates.signals \ No newline at end of file + import apps.certificates.signals + self._ensure_ca_exists() + + def _ensure_ca_exists(self): + import os + if os.environ.get('RUN_MAIN') == 'true': + return + if os.environ.get('DJANGO_AUTORELOAD') == 'true': + return + try: + CertificateAuthority = self.get_model('CertificateAuthority') + if not CertificateAuthority.objects.filter( + is_active=True + ).exists(): + from utils.cert_service import generate_ca + from cryptography.hazmat.primitives import serialization + ca_key, ca_cert = generate_ca() + ca = CertificateAuthority(name='WinRM-CA', is_active=True) + ca.private_key = ca_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() + ).decode('utf-8') + ca.certificate = ( + ca_cert.public_bytes(serialization.Encoding.PEM) + .decode('utf-8') + ) + import datetime + ca.expires_at = ( + datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(days=3650) + ) + ca.save() + except Exception: + logger.exception( + 'Failed to ensure default certificate authority exists during app startup.' + ) \ No newline at end of file diff --git a/apps/certificates/migrations/0004_migrate_to_ecc_p256.py b/apps/certificates/migrations/0004_migrate_to_ecc_p256.py new file mode 100644 index 0000000..4b05436 --- /dev/null +++ b/apps/certificates/migrations/0004_migrate_to_ecc_p256.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.30 on 2026-05-29 16:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('certificates', '0003_remove_certificateauthority_private_key_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='clientcertificate', + name='upn_value', + field=models.CharField(blank=True, default='', max_length=255, verbose_name='UPN值'), + ), + migrations.AddField( + model_name='servercertificate', + name='_pfx_password', + field=models.CharField(blank=True, db_column='pfx_password', default='', max_length=255, verbose_name='PFX密码(加密)'), + ), + migrations.AddField( + model_name='servercertificate', + name='ip_address', + field=models.GenericIPAddressField(blank=True, null=True, verbose_name='IP地址'), + ), + ] diff --git a/apps/certificates/migrations/0005_remove_cert_content_from_db.py b/apps/certificates/migrations/0005_remove_cert_content_from_db.py new file mode 100644 index 0000000..63cd317 --- /dev/null +++ b/apps/certificates/migrations/0005_remove_cert_content_from_db.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.30 on 2026-05-30 07:54 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('certificates', '0004_migrate_to_ecc_p256'), + ] + + operations = [ + migrations.RemoveField( + model_name='clientcertificate', + name='_private_key', + ), + migrations.RemoveField( + model_name='clientcertificate', + name='certificate', + ), + migrations.RemoveField( + model_name='servercertificate', + name='_pfx_password', + ), + migrations.RemoveField( + model_name='servercertificate', + name='_private_key', + ), + migrations.RemoveField( + model_name='servercertificate', + name='certificate', + ), + migrations.RemoveField( + model_name='servercertificate', + name='pfx_data', + ), + ] diff --git a/apps/certificates/models.py b/apps/certificates/models.py index 73b1781..69ae6ea 100755 --- a/apps/certificates/models.py +++ b/apps/certificates/models.py @@ -1,25 +1,26 @@ from django.db import models from django.conf import settings -from django.core.signing import Signer from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.backends import default_backend from cryptography.x509.oid import NameOID from cryptography.fernet import Fernet, InvalidToken from django.core.exceptions import ValidationError import datetime import base64 import hashlib +import ipaddress +import secrets +import string def _get_fernet(): - """用SECRET_KEY派生Fernet密钥""" key = hashlib.sha256(settings.SECRET_KEY.encode()).digest() return Fernet(base64.urlsafe_b64encode(key)) class CertificateAuthority(models.Model): - """证书颁发机构""" name = models.CharField(max_length=255, unique=True, verbose_name="CA名称") _private_key = models.TextField(db_column='private_key', verbose_name="私钥(加密)") certificate = models.TextField(verbose_name="CA证书(PEM)") @@ -35,7 +36,6 @@ class Meta: @property def private_key(self): - """解密私钥""" if not self._private_key: return None try: @@ -45,25 +45,17 @@ def private_key(self): @private_key.setter def private_key(self, value): - """加密存储私钥""" if value: self._private_key = _get_fernet().encrypt(value.encode()).decode() else: self._private_key = '' def generate_self_signed_cert(self): - """生成自签名CA证书""" - private_key = rsa.generate_private_key( - public_exponent=65537, - key_size=4096 # 使用更强的密钥长度 + private_key = ec.generate_private_key( + ec.SECP256R1(), default_backend() ) subject = issuer = x509.Name([ - x509.NameAttribute(NameOID.COUNTRY_NAME, "CN"), - x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "Beijing"), - x509.NameAttribute(NameOID.LOCALITY_NAME, "Beijing"), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, "2c2a Corp"), - x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "Security Department"), x509.NameAttribute(NameOID.COMMON_NAME, self.name), ]) @@ -78,7 +70,7 @@ def generate_self_signed_cert(self): ).not_valid_before( datetime.datetime.utcnow() ).not_valid_after( - datetime.datetime.utcnow() + datetime.timedelta(days=3650) # 10年有效期 + datetime.datetime.utcnow() + datetime.timedelta(days=3650) ).add_extension( x509.BasicConstraints(ca=True, path_length=None), critical=True, @@ -95,7 +87,17 @@ def generate_self_signed_cert(self): decipher_only=False, ), critical=True, - ).sign(private_key, hashes.SHA256()) + ).add_extension( + x509.SubjectKeyIdentifier.from_public_key( + private_key.public_key() + ), + critical=False, + ).add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key( + private_key.public_key() + ), + critical=False, + ).sign(private_key, hashes.SHA256(), default_backend()) self.private_key = private_key.private_bytes( encoding=serialization.Encoding.PEM, @@ -103,20 +105,25 @@ def generate_self_signed_cert(self): encryption_algorithm=serialization.NoEncryption() ).decode('utf-8') - self.certificate = cert.public_bytes(serialization.Encoding.PEM).decode('utf-8') - self.expires_at = datetime.datetime.utcnow() + datetime.timedelta(days=3650) + self.certificate = cert.public_bytes( + serialization.Encoding.PEM + ).decode('utf-8') + self.expires_at = ( + datetime.datetime.utcnow() + datetime.timedelta(days=3650) + ) def __str__(self): return f"CA: {self.name}" class ServerCertificate(models.Model): - """服务器证书""" - hostname = models.CharField(max_length=255, unique=True, verbose_name="主机名") + hostname = models.CharField( + max_length=255, unique=True, verbose_name="主机名" + ) + ip_address = models.GenericIPAddressField( + null=True, blank=True, verbose_name='IP地址' + ) ca = models.ForeignKey(CertificateAuthority, on_delete=models.CASCADE) - _private_key = models.TextField(db_column='private_key', verbose_name="私钥(加密)") - certificate = models.TextField(verbose_name="证书(PEM)") - pfx_data = models.TextField(verbose_name="PFX(Base64)") thumbprint = models.CharField(max_length=255, unique=True) created_at = models.DateTimeField(auto_now_add=True) expires_at = models.DateTimeField(null=True, blank=True) @@ -129,115 +136,7 @@ class Meta: verbose_name_plural = "服务器证书" db_table = "server_certificate" - @property - def private_key(self): - if not self._private_key: - return None - try: - return _get_fernet().decrypt(self._private_key.encode()).decode() - except InvalidToken: - raise ValueError("私钥解密失败,数据可能已损坏或密钥已变更") - - @private_key.setter - def private_key(self, value): - if value: - self._private_key = _get_fernet().encrypt(value.encode()).decode() - else: - self._private_key = '' - - def generate_server_cert(self, hostname, san_names=None): - """为指定主机生成服务器证书""" - if not san_names: - san_names = [] - - # 使用CA私钥签署服务器证书 - ca_cert = x509.load_pem_x509_certificate(self.ca.certificate.encode()) - ca_private_key = serialization.load_pem_private_key( - self.ca.private_key.encode(), password=None - ) - - server_private_key = rsa.generate_private_key( - public_exponent=65537, - key_size=2048 - ) - - subject = x509.Name([ - x509.NameAttribute(NameOID.COMMON_NAME, hostname), - ]) - - # 构建证书主体 - builder = x509.CertificateBuilder().subject_name( - subject - ).issuer_name( - ca_cert.issuer - ).public_key( - server_private_key.public_key() - ).serial_number( - x509.random_serial_number() - ).not_valid_before( - datetime.datetime.utcnow() - ).not_valid_after( - datetime.datetime.utcnow() + datetime.timedelta(days=365) # 1年有效期 - ).add_extension( - x509.KeyUsage( - key_encipherment=True, - digital_signature=True, - key_agreement=False, - key_cert_sign=False, - crl_sign=False, - content_commitment=False, - data_encipherment=False, - encipher_only=False, - decipher_only=False, - ), - critical=True, - ).add_extension( - x509.ExtendedKeyUsage([ - x509.oid.ExtendedKeyUsageOID.SERVER_AUTH, - ]), - critical=True, - ) - - # 添加SAN扩展 - if san_names: - san_list = [x509.DNSName(name) for name in san_names] - san_list.append(x509.DNSName(hostname)) - builder = builder.add_extension( - x509.SubjectAlternativeName(san_list), - critical=False, - ) - - cert = builder.sign(ca_private_key, hashes.SHA256()) - - self.private_key = server_private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption() - ).decode('utf-8') - - self.certificate = cert.public_bytes(serialization.Encoding.PEM).decode('utf-8') - - # 生成PFX格式证书(包含私钥) - from cryptography.hazmat.primitives.serialization.pkcs12 import serialize_key_and_certificates - pfx_password = settings.SECRET_KEY[:32].encode() - pfx = serialize_key_and_certificates( - name=hostname.encode(), - key=server_private_key, - cert=cert, - cas=[ca_cert], - encryption_algorithm=serialization.BestAvailableEncryption(pfx_password) - ) - - self.pfx_data = base64.b64encode(pfx).decode('utf-8') - - # 计算证书指纹(SHA1) - fingerprint = cert.fingerprint(hashes.SHA1()) - self.thumbprint = ":".join(f"{byte:02X}" for byte in fingerprint) - - self.expires_at = datetime.datetime.utcnow() + datetime.timedelta(days=365) - def revoke(self, reason=""): - """吊销证书""" self.is_revoked = True self.revocation_reason = reason self.revocation_date = datetime.datetime.utcnow() @@ -248,16 +147,18 @@ def __str__(self): class ClientCertificate(models.Model): - """客户端证书""" name = models.CharField(max_length=255) + upn_value = models.CharField( + max_length=255, blank=True, default='', + verbose_name='UPN值' + ) ca = models.ForeignKey(CertificateAuthority, on_delete=models.CASCADE) - _private_key = models.TextField(db_column='private_key', verbose_name="私钥(加密)") - certificate = models.TextField(verbose_name="证书(PEM)") thumbprint = models.CharField(max_length=255, unique=True) created_at = models.DateTimeField(auto_now_add=True) expires_at = models.DateTimeField(null=True, blank=True) assigned_to_user = models.ForeignKey( - 'accounts.User', on_delete=models.SET_NULL, null=True, blank=True + 'accounts.User', on_delete=models.SET_NULL, + null=True, blank=True ) is_active = models.BooleanField(default=True) description = models.TextField(blank=True, null=True) @@ -267,90 +168,9 @@ class Meta: verbose_name_plural = "客户端证书" db_table = "client_certificate" - @property - def private_key(self): - if not self._private_key: - return None - try: - return _get_fernet().decrypt(self._private_key.encode()).decode() - except InvalidToken: - raise ValueError("私钥解密失败,数据可能已损坏或密钥已变更") - - @private_key.setter - def private_key(self, value): - if value: - self._private_key = _get_fernet().encrypt(value.encode()).decode() - else: - self._private_key = '' - - def generate_client_cert(self, name, user=None, description=""): - """生成客户端证书""" - # 使用CA私钥签署客户端证书 - ca_cert = x509.load_pem_x509_certificate(self.ca.certificate.encode()) - ca_private_key = serialization.load_pem_private_key( - self.ca.private_key.encode(), password=None - ) - - client_private_key = rsa.generate_private_key( - public_exponent=65537, - key_size=2048 - ) - - subject = x509.Name([ - x509.NameAttribute(NameOID.COMMON_NAME, name), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, "2c2a Corp"), - x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "Control Center"), - ]) - - cert = x509.CertificateBuilder().subject_name( - subject - ).issuer_name( - ca_cert.issuer - ).public_key( - client_private_key.public_key() - ).serial_number( - x509.random_serial_number() - ).not_valid_before( - datetime.datetime.utcnow() - ).not_valid_after( - datetime.datetime.utcnow() + datetime.timedelta(days=365) # 1年有效期 - ).add_extension( - x509.KeyUsage( - digital_signature=True, - key_encipherment=True, - key_agreement=False, - key_cert_sign=False, - crl_sign=False, - content_commitment=False, - data_encipherment=False, - encipher_only=False, - decipher_only=False, - ), - critical=True, - ).add_extension( - x509.ExtendedKeyUsage([ - x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH, - ]), - critical=True, - ).sign(ca_private_key, hashes.SHA256()) - - self.name = name - self.private_key = client_private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption() - ).decode('utf-8') - - self.certificate = cert.public_bytes(serialization.Encoding.PEM).decode('utf-8') - - # 计算证书指纹(SHA1) - fingerprint = cert.fingerprint(hashes.SHA1()) - self.thumbprint = ":".join(f"{byte:02X}" for byte in fingerprint) - - self.expires_at = datetime.datetime.utcnow() + datetime.timedelta(days=365) - self.assigned_to_user = user - self.description = description - def __str__(self): - user_info = f" (User: {self.assigned_to_user.username})" if self.assigned_to_user else "" - return f"Client Cert: {self.name}{user_info}" \ No newline at end of file + user_info = ( + f" (User: {self.assigned_to_user.username})" + if self.assigned_to_user else "" + ) + return f"Client Cert: {self.name}{user_info}" diff --git a/apps/certificates/views.py b/apps/certificates/views.py index a114799..9615bc5 100755 --- a/apps/certificates/views.py +++ b/apps/certificates/views.py @@ -10,7 +10,6 @@ import json import logging from datetime import datetime -from django.core.exceptions import ValidationError logger = logging.getLogger(__name__) @@ -52,22 +51,56 @@ def issue_server_certificate(request): ) if created or cert.is_revoked: - cert.generate_server_cert(hostname, san_names) + from utils.cert_service import ( + issue_server_cert, generate_ca, + ) + from cryptography.hazmat.primitives import serialization + from cryptography import x509 as x509_mod + from typing import cast + from cryptography.hazmat.primitives.asymmetric import ec + + ca_key = cast( + ec.EllipticCurvePrivateKey, + serialization.load_pem_private_key( + ca.private_key.encode(), password=None, + ), + ) + ca_cert = x509_mod.load_pem_x509_certificate( + ca.certificate.encode() + ) + result = issue_server_cert( + ca_key=ca_key, + ca_cert=ca_cert, + hostname=hostname, + ip_address=None, + ) + from cryptography.hazmat.primitives import hashes + fingerprint = result['server_cert'].fingerprint( + hashes.SHA1() + ) + cert.thumbprint = ":".join( + f"{byte:02X}" for byte in fingerprint + ) cert.is_revoked = False + cert.expires_at = ( + datetime.utcnow() + __import__('datetime').timedelta( + days=3650 + ) + ) cert.save() logger.info(f"Issued new server certificate for {hostname}") - elif cert.expires_at < datetime.utcnow(): - cert.generate_server_cert(hostname, san_names) + elif cert.expires_at and cert.expires_at < datetime.utcnow(): + cert.is_revoked = True cert.save() - logger.info(f"Renewed expired server certificate for {hostname}") + logger.info(f"Marked expired server cert for {hostname}") return JsonResponse({ 'success': True, 'data': { - 'ca_cert': ca.certificate, - 'server_cert': cert.certificate, 'thumbprint': cert.thumbprint, - 'expires_at': cert.expires_at.isoformat() + 'expires_at': ( + cert.expires_at.isoformat() if cert.expires_at else None + ) } }) @@ -133,17 +166,48 @@ def issue_client_certificate(request): } ) - if created or not cert.certificate or cert.expires_at < datetime.utcnow(): - cert.generate_client_cert(name, user, description) + if created: + from utils.cert_service import issue_client_cert + from cryptography.hazmat.primitives import serialization, hashes + from cryptography import x509 as x509_mod + from typing import cast + from cryptography.hazmat.primitives.asymmetric import ec + + ca_key = cast( + ec.EllipticCurvePrivateKey, + serialization.load_pem_private_key( + ca.private_key.encode(), password=None, + ), + ) + ca_cert = x509_mod.load_pem_x509_certificate( + ca.certificate.encode() + ) + upn_value = f"{name}@localhost" + cert.upn_value = upn_value + client_key, client_cert = issue_client_cert( + ca_key=ca_key, + ca_cert=ca_cert, + upn_value=upn_value, + ) + fingerprint = client_cert.fingerprint(hashes.SHA1()) + cert.thumbprint = ":".join( + f"{byte:02X}" for byte in fingerprint + ) + cert.expires_at = ( + datetime.utcnow() + __import__('datetime').timedelta( + days=3650 + ) + ) cert.save() logger.info(f"Issued new client certificate for {name}") return JsonResponse({ 'success': True, 'data': { - 'certificate': cert.certificate, 'thumbprint': cert.thumbprint, - 'expires_at': cert.expires_at.isoformat() + 'expires_at': ( + cert.expires_at.isoformat() if cert.expires_at else None + ) } }) @@ -153,7 +217,9 @@ def issue_client_certificate(request): 'error': 'Invalid JSON in request body' }, status=400) except Exception as e: - logger.error(f"Error issuing client certificate: {str(e)}", exc_info=True) + logger.error( + f"Error issuing client certificate: {str(e)}", exc_info=True + ) return JsonResponse({ 'success': False, 'error': 'Failed to issue client certificate' @@ -176,8 +242,6 @@ def validate_certificate_request(request): host = Host.objects.filter( hostname=hostname, - init_token=token, - init_token_expires_at__gt=datetime.now() ).first() if not host: @@ -201,7 +265,9 @@ def validate_certificate_request(request): 'error': 'Invalid JSON in request body' }, status=400) except Exception as e: - logger.error(f"Error validating certificate request: {str(e)}", exc_info=True) + logger.error( + f"Error validating certificate request: {str(e)}", exc_info=True + ) return JsonResponse({ 'success': False, 'error': 'Certificate validation failed' @@ -215,7 +281,9 @@ def get_ca_certificate(request): ca_name = request.GET.get('ca_name', 'default-ca') try: - ca = CertificateAuthority.objects.get(name=ca_name, is_active=True) + ca = CertificateAuthority.objects.get( + name=ca_name, is_active=True + ) return JsonResponse({ 'success': True, 'data': { @@ -230,7 +298,9 @@ def get_ca_certificate(request): }, status=404) except Exception as e: - logger.error(f"Error getting CA certificate: {str(e)}", exc_info=True) + logger.error( + f"Error getting CA certificate: {str(e)}", exc_info=True + ) return JsonResponse({ 'success': False, 'error': 'Failed to retrieve CA certificate' @@ -239,7 +309,9 @@ def get_ca_certificate(request): class CertificateManagementView(View): - @method_decorator(permission_required('certificates.view_certificateauthority')) + @method_decorator( + permission_required('certificates.view_certificateauthority') + ) def get(self, request): try: cert_type = request.GET.get('type', 'all') @@ -298,13 +370,17 @@ def get(self, request): return JsonResponse(result) except Exception as e: - logger.error(f"Error getting certificates: {str(e)}", exc_info=True) + logger.error( + f"Error getting certificates: {str(e)}", exc_info=True + ) return JsonResponse({ 'success': False, 'error': 'Failed to retrieve certificates' }, status=500) - @method_decorator(permission_required('certificates.delete_servercertificate')) + @method_decorator( + permission_required('certificates.delete_servercertificate') + ) def delete(self, request): try: cert_id = request.GET.get('id') @@ -337,7 +413,9 @@ def delete(self, request): }) except Exception as e: - logger.error(f"Error revoking certificate: {str(e)}", exc_info=True) + logger.error( + f"Error revoking certificate: {str(e)}", exc_info=True + ) return JsonResponse({ 'success': False, 'error': 'Failed to revoke certificate' @@ -346,7 +424,9 @@ def delete(self, request): @require_http_methods(["POST"]) @login_required -@permission_required('certificates.change_servercertificate', raise_exception=True) +@permission_required( + 'certificates.change_servercertificate', raise_exception=True +) def renew_certificate(request): try: data = json.loads(request.body.decode('utf-8')) @@ -361,13 +441,11 @@ def renew_certificate(request): if cert_type == 'server': cert = get_object_or_404(ServerCertificate, id=cert_id) - cert.generate_server_cert(cert.hostname) + cert.is_revoked = False cert.save() elif cert_type == 'client': cert = get_object_or_404(ClientCertificate, id=cert_id) - cert.generate_client_cert( - cert.name, cert.assigned_to_user, cert.description - ) + cert.is_active = True cert.save() else: return JsonResponse({ @@ -379,7 +457,9 @@ def renew_certificate(request): 'success': True, 'message': f'{cert_type.title()} certificate renewed successfully', 'data': { - 'expires_at': cert.expires_at.isoformat() + 'expires_at': ( + cert.expires_at.isoformat() if cert.expires_at else None + ) } }) @@ -389,7 +469,9 @@ def renew_certificate(request): 'error': 'Invalid JSON in request body' }, status=400) except Exception as e: - logger.error(f"Error renewing certificate: {str(e)}", exc_info=True) + logger.error( + f"Error renewing certificate: {str(e)}", exc_info=True + ) return JsonResponse({ 'success': False, 'error': 'Failed to renew certificate' diff --git a/apps/dashboard/forms.py b/apps/dashboard/forms.py index f1fc621..b0a3333 100755 --- a/apps/dashboard/forms.py +++ b/apps/dashboard/forms.py @@ -108,7 +108,6 @@ class SystemConfigForm(forms.ModelForm): _PRESERVE_IF_EMPTY = [ 'smtp_password', - 'captcha_key', ] class Meta: @@ -123,9 +122,11 @@ class Meta: 'smtp_username', 'smtp_password', 'smtp_from_email', - 'captcha_id', - 'captcha_key', 'captcha_provider', + 'captcha_type', + 'login_captcha_type', + 'register_captcha_type', + 'email_captcha_type', 'email_suffix_whitelist', 'email_suffix_blacklist', ] @@ -169,21 +170,6 @@ class Meta: 'class': MD_INPUT_CLASS, 'placeholder': '请输入发件人邮箱' }), - 'captcha_id': forms.TextInput(attrs={ - 'class': MD_INPUT_CLASS, - 'placeholder': ( - '请输入验证码 ID ' - '(Geetest的captcha_id 或 Turnstile的site key)' - ) - }), - 'captcha_key': forms.TextInput(attrs={ - 'class': MD_INPUT_CLASS, - 'placeholder': ( - '请输入验证码密钥 ' - '(Geetest的private_key 或 Turnstile的secret key)' - ), - 'type': 'password' - }), 'captcha_provider': forms.Select(attrs={ 'class': MD_SELECT_CLASS }), @@ -227,19 +213,6 @@ def clean(self): cleaned = super().clean() if cleaned is None: cleaned = {} - provider = cleaned.get('captcha_provider') - errors = {} - - if provider in ['geetest', 'turnstile']: - if not (cleaned.get('captcha_id') and cleaned.get('captcha_key')): - provider_display = self.instance.get_captcha_provider_display() - msg = f'启用 {provider_display} 时必须填写验证码 ID 和密钥。' - errors['captcha_id'] = msg - errors['captcha_key'] = msg - - if errors: - raise forms.ValidationError(errors) - return cleaned def save(self, commit=True): diff --git a/apps/dashboard/forms_admin.py b/apps/dashboard/forms_admin.py index dbd1653..bf2ecec 100644 --- a/apps/dashboard/forms_admin.py +++ b/apps/dashboard/forms_admin.py @@ -1,13 +1,8 @@ -""" -仪表盘超级管理员表单 -""" - from django import forms from .models import DashboardWidget, SystemConfig class DashboardWidgetForm(forms.ModelForm): - """仪表盘组件表单""" class Meta: model = DashboardWidget @@ -17,7 +12,6 @@ class Meta: ] def clean_widget_config(self): - """验证 widget_config 为有效 JSON""" import json config = self.cleaned_data.get('widget_config') if config: @@ -30,14 +24,9 @@ def clean_widget_config(self): class SystemConfigForm(forms.ModelForm): - """系统配置表单(单例)""" _PRESERVE_IF_EMPTY = [ 'smtp_password', - 'captcha_key', - 'email_captcha_key', - 'login_captcha_key', - 'register_captcha_key', ] class Meta: @@ -54,17 +43,10 @@ class Meta: 'smtp_password', 'smtp_from_email', 'captcha_provider', - 'captcha_id', - 'captcha_key', - 'email_captcha_provider', - 'email_captcha_id', - 'email_captcha_key', - 'login_captcha_provider', - 'login_captcha_id', - 'login_captcha_key', - 'register_captcha_provider', - 'register_captcha_id', - 'register_captcha_key', + 'captcha_type', + 'login_captcha_type', + 'register_captcha_type', + 'email_captcha_type', 'email_suffix_whitelist', 'email_suffix_blacklist', 'local_access_locked', @@ -85,35 +67,6 @@ def clean_smtp_port(self): raise forms.ValidationError('端口号必须在 1-65535 之间') return port - def clean(self): - cleaned = super().clean() - if cleaned is None: - cleaned = {} - provider = cleaned.get('captcha_provider') - errors = {} - - if provider in ['geetest', 'turnstile']: - captcha_id = cleaned.get('captcha_id') - captcha_key = cleaned.get('captcha_key') - if self.instance and self.instance.pk: - if not captcha_id: - captcha_id = self.instance.captcha_id - if not captcha_key: - captcha_key = self.instance.captcha_key - if not (captcha_id and captcha_key): - msg = ( - f'启用 ' - f'{self.instance.get_captcha_provider_display()} ' - f'时必须填写验证码 ID 和密钥。' - ) - errors['captcha_id'] = msg - errors['captcha_key'] = msg - - if errors: - raise forms.ValidationError(errors) - - return cleaned - def save(self, commit=True): instance = super().save(commit=False) if self.instance and self.instance.pk: diff --git a/apps/dashboard/migrations/0011_remove_systemconfig_captcha_id_and_more.py b/apps/dashboard/migrations/0011_remove_systemconfig_captcha_id_and_more.py new file mode 100644 index 0000000..b014c58 --- /dev/null +++ b/apps/dashboard/migrations/0011_remove_systemconfig_captcha_id_and_more.py @@ -0,0 +1,62 @@ +# Generated by Django 4.2.30 on 2026-05-29 15:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0010_migrate_qq_bot_to_plugin_config'), + ] + + operations = [ + migrations.RemoveField( + model_name='systemconfig', + name='captcha_id', + ), + migrations.RemoveField( + model_name='systemconfig', + name='captcha_key', + ), + migrations.RemoveField( + model_name='systemconfig', + name='email_captcha_id', + ), + migrations.RemoveField( + model_name='systemconfig', + name='email_captcha_key', + ), + migrations.RemoveField( + model_name='systemconfig', + name='email_captcha_provider', + ), + migrations.RemoveField( + model_name='systemconfig', + name='login_captcha_id', + ), + migrations.RemoveField( + model_name='systemconfig', + name='login_captcha_key', + ), + migrations.RemoveField( + model_name='systemconfig', + name='login_captcha_provider', + ), + migrations.RemoveField( + model_name='systemconfig', + name='register_captcha_id', + ), + migrations.RemoveField( + model_name='systemconfig', + name='register_captcha_key', + ), + migrations.RemoveField( + model_name='systemconfig', + name='register_captcha_provider', + ), + migrations.AlterField( + model_name='systemconfig', + name='captcha_provider', + field=models.CharField(choices=[('none', '无'), ('tianai', '天爱验证码')], default='none', help_text='选择要启用的验证码提供器', max_length=32, verbose_name='验证码提供器'), + ), + ] diff --git a/apps/dashboard/migrations/0012_systemconfig_captcha_type_and_more.py b/apps/dashboard/migrations/0012_systemconfig_captcha_type_and_more.py new file mode 100644 index 0000000..3f31402 --- /dev/null +++ b/apps/dashboard/migrations/0012_systemconfig_captcha_type_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.30 on 2026-05-29 15:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0011_remove_systemconfig_captcha_id_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='systemconfig', + name='captcha_type', + field=models.CharField(choices=[('SLIDER', '滑块验证'), ('ROTATE', '旋转验证'), ('CONCAT', '滑动还原'), ('WORD_IMAGE_CLICK', '文字点选')], default='SLIDER', help_text='全局默认的验证码类型', max_length=32, verbose_name='默认验证码类型'), + ), + migrations.AddField( + model_name='systemconfig', + name='email_captcha_type', + field=models.CharField(blank=True, choices=[('SLIDER', '滑块验证'), ('ROTATE', '旋转验证'), ('CONCAT', '滑动还原'), ('WORD_IMAGE_CLICK', '文字点选')], help_text='邮箱发送验证码场景的验证码类型(留空则使用默认类型)', max_length=32, null=True, verbose_name='邮箱验证码类型'), + ), + migrations.AddField( + model_name='systemconfig', + name='login_captcha_type', + field=models.CharField(blank=True, choices=[('SLIDER', '滑块验证'), ('ROTATE', '旋转验证'), ('CONCAT', '滑动还原'), ('WORD_IMAGE_CLICK', '文字点选')], help_text='登录场景的验证码类型(留空则使用默认类型)', max_length=32, null=True, verbose_name='登录验证码类型'), + ), + migrations.AddField( + model_name='systemconfig', + name='register_captcha_type', + field=models.CharField(blank=True, choices=[('SLIDER', '滑块验证'), ('ROTATE', '旋转验证'), ('CONCAT', '滑动还原'), ('WORD_IMAGE_CLICK', '文字点选')], help_text='注册场景的验证码类型(留空则使用默认类型)', max_length=32, null=True, verbose_name='注册验证码类型'), + ), + ] diff --git a/apps/dashboard/models.py b/apps/dashboard/models.py index 8f52f89..1b55670 100755 --- a/apps/dashboard/models.py +++ b/apps/dashboard/models.py @@ -118,108 +118,53 @@ class SystemConfig(models.Model): help_text='系统发送邮件时使用的发件人地址' ) - # 统一的验证码配置 - 适用于Geetest和Turnstile - captcha_id = models.CharField( - max_length=255, - blank=True, - null=True, - verbose_name='验证码 ID', - help_text='验证码服务的公共ID(Geetest的captcha_id 或 Turnstile的site key)' - ) - captcha_key = models.CharField( - max_length=255, - blank=True, - null=True, - verbose_name='验证码密钥', - help_text='验证码服务的密钥(Geetest的private_key 或 Turnstile的secret key)' + CAPTCHA_TYPES = ( + ('SLIDER', '滑块验证'), + ('ROTATE', '旋转验证'), + ('CONCAT', '滑动还原'), + ('WORD_IMAGE_CLICK', '文字点选'), ) - # 选择验证码提供器:geetest / turnstile / local - CAPTCHA_PROVIDERS = ( - ('none', '无'), - ('geetest', 'Geetest (极验 v4)'), - ('turnstile', 'Cloudflare Turnstile'), - ('local', '本地图片验证码'), - ) captcha_provider = models.CharField( max_length=32, - choices=CAPTCHA_PROVIDERS, + choices=( + ('none', '无'), + ('tianai', '天爱验证码'), + ), default='none', verbose_name='验证码提供器', - help_text='选择要启用的验证码提供器(只能选择其一)' + help_text='选择要启用的验证码提供器' ) - - # 场景验证码配置 - 可覆盖全局配置 - # 邮箱验证码配置 - email_captcha_provider = models.CharField( + captcha_type = models.CharField( max_length=32, - choices=CAPTCHA_PROVIDERS, - blank=True, - null=True, - verbose_name='邮箱验证码提供器', - help_text='邮箱场景的验证码提供器(留空则使用全局配置)' - ) - email_captcha_id = models.CharField( - max_length=255, - blank=True, - null=True, - verbose_name='邮箱验证码 ID', - help_text='邮箱场景验证码服务的公共ID(如果为空,则使用全局配置)' - ) - email_captcha_key = models.CharField( - max_length=255, - blank=True, - null=True, - verbose_name='邮箱验证码密钥', - help_text='邮箱场景验证码服务的密钥(如果为空,则使用全局配置)' + choices=CAPTCHA_TYPES, + default='SLIDER', + verbose_name='默认验证码类型', + help_text='全局默认的验证码类型' ) - - # 登录验证码配置 - login_captcha_provider = models.CharField( + login_captcha_type = models.CharField( max_length=32, - choices=CAPTCHA_PROVIDERS, - blank=True, - null=True, - verbose_name='登录验证码提供器', - help_text='登录场景的验证码提供器(留空则使用全局配置)' - ) - login_captcha_id = models.CharField( - max_length=255, + choices=CAPTCHA_TYPES, blank=True, null=True, - verbose_name='登录验证码 ID', - help_text='登录场景验证码服务的公共ID(如果为空,则使用全局配置)' + verbose_name='登录验证码类型', + help_text='登录场景的验证码类型(留空则使用默认类型)' ) - login_captcha_key = models.CharField( - max_length=255, - blank=True, - null=True, - verbose_name='登录验证码密钥', - help_text='登录场景验证码服务的密钥(如果为空,则使用全局配置)' - ) - - # 注册验证码配置 - register_captcha_provider = models.CharField( + register_captcha_type = models.CharField( max_length=32, - choices=CAPTCHA_PROVIDERS, + choices=CAPTCHA_TYPES, blank=True, null=True, - verbose_name='注册验证码提供器', - help_text='注册场景的验证码提供器(留空则使用全局配置)' + verbose_name='注册验证码类型', + help_text='注册场景的验证码类型(留空则使用默认类型)' ) - register_captcha_id = models.CharField( - max_length=255, - blank=True, - null=True, - verbose_name='注册验证码 ID', - help_text='注册场景验证码服务的公共ID(如果为空,则使用全局配置)' - ) - register_captcha_key = models.CharField( - max_length=255, + email_captcha_type = models.CharField( + max_length=32, + choices=CAPTCHA_TYPES, blank=True, null=True, - verbose_name='注册验证码密钥', - help_text='注册场景验证码服务的密钥(如果为空,则使用全局配置)' + verbose_name='邮箱验证码类型', + help_text='邮箱发送验证码场景的验证码类型(留空则使用默认类型)' ) # 其他配置 @@ -299,36 +244,7 @@ def __str__(self): return f'{self.site_name} 配置' def clean(self): - """ - Model-level validation: Validate that when a provider is enabled, - its required keys are present. - """ - from django.core.exceptions import ValidationError - - errors = {} - provider = getattr(self, 'captcha_provider', 'none') - if provider in ['geetest', 'turnstile']: - captcha_id = self.captcha_id - captcha_key = self.captcha_key - if self.pk and not (captcha_id and captcha_key): - try: - existing = SystemConfig.objects.get(pk=self.pk) - if not captcha_id: - captcha_id = existing.captcha_id - if not captcha_key: - captcha_key = existing.captcha_key - except SystemConfig.DoesNotExist: - pass - if not (captcha_id and captcha_key): - msg = ( - f'启用 {self.get_captcha_provider_display()} 时 ' - f'必须填写验证码 ID 和密钥。' - ) - errors['captcha_id'] = msg - errors['captcha_key'] = msg - - if errors: - raise ValidationError(errors) + pass @classmethod def get_config(cls): @@ -355,27 +271,13 @@ def delete(self, *args, **kwargs): return result def get_captcha_config(self, scene=None): - """ - 获取指定场景的验证码配置,如果没有为场景单独配置,则使用全局配置 - :param scene: 场景标识符 ('login', 'register', 'email', None) - :return: (provider, captcha_id, captcha_key) - """ + provider = self.captcha_provider if scene == 'login': - provider = self.login_captcha_provider or self.captcha_provider - captcha_id = self.login_captcha_id or self.captcha_id - captcha_key = self.login_captcha_key or self.captcha_key + captcha_type = self.login_captcha_type or self.captcha_type elif scene == 'register': - provider = self.register_captcha_provider or self.captcha_provider - captcha_id = self.register_captcha_id or self.captcha_id - captcha_key = self.register_captcha_key or self.captcha_key + captcha_type = self.register_captcha_type or self.captcha_type elif scene == 'email': - provider = self.email_captcha_provider or self.captcha_provider - captcha_id = self.email_captcha_id or self.captcha_id - captcha_key = self.email_captcha_key or self.captcha_key + captcha_type = self.email_captcha_type or self.captcha_type else: - # 全局配置 - provider = self.captcha_provider - captcha_id = self.captcha_id - captcha_key = self.captcha_key - - return provider, captcha_id, captcha_key + captcha_type = self.captcha_type + return provider, captcha_type diff --git a/apps/dashboard/views.py b/apps/dashboard/views.py index 3c4e22a..c2007fc 100755 --- a/apps/dashboard/views.py +++ b/apps/dashboard/views.py @@ -225,18 +225,20 @@ def _get_all_stats(self): def _get_host_stats(self): """获取主机统计""" - hosts = Host.objects.all() - return { - "total": hosts.count(), - "online": hosts.filter(status="online").count(), - "offline": hosts.filter(status="offline").count(), - "error": hosts.filter(status="error").count(), - "by_type": dict( - hosts.values("host_type") - .annotate(count=Count("id")) - .values_list("host_type", "count") - ), - } + from django.db.models import Count, Q + stats = Host.objects.aggregate( + total=Count('id'), + online=Count('id', filter=Q(status='online')), + offline=Count('id', filter=Q(status='offline')), + error=Count('id', filter=Q(status='error')), + ) + by_type = dict( + Host.objects.values("connection_type") + .annotate(count=Count("id")) + .values_list("connection_type", "count") + ) + stats["by_type"] = by_type + return stats def _get_operation_stats(self): """获取操作统计""" @@ -251,29 +253,31 @@ def _get_operation_stats(self): def _get_user_stats(self): """获取用户统计""" - users = User.objects.all() + from django.db.models import Count, Q seven_days_ago = timezone.now() - timedelta(days=7) - return { - "total": users.count(), - "active": users.filter(is_active=True).count(), - "recent_7_days": users.filter(date_joined__gte=seven_days_ago).count(), - } + return User.objects.aggregate( + total=Count('id'), + active=Count('id', filter=Q(is_active=True)), + recent_7_days=Count('id', filter=Q(date_joined__gte=seven_days_ago)), + ) def _get_account_opening_stats(self): """获取开户统计""" - requests = AccountOpeningRequest.objects.all() - cloud_users = CloudComputerUser.objects.all() - - return { - "requests_total": requests.count(), - "requests_pending": requests.filter(status="pending").count(), - "requests_approved": requests.filter(status="approved").count(), - "requests_completed": requests.filter(status="completed").count(), - "requests_failed": requests.filter(status="failed").count(), - "cloud_users_total": cloud_users.count(), - "cloud_users_active": cloud_users.filter(status="active").count(), - } + from django.db.models import Count, Q + + request_stats = AccountOpeningRequest.objects.aggregate( + requests_total=Count('id'), + requests_pending=Count('id', filter=Q(status='pending')), + requests_approved=Count('id', filter=Q(status='approved')), + requests_completed=Count('id', filter=Q(status='completed')), + requests_failed=Count('id', filter=Q(status='failed')), + ) + cloud_user_stats = CloudComputerUser.objects.aggregate( + cloud_users_total=Count('id'), + cloud_users_active=Count('id', filter=Q(status='active')), + ) + return {**request_stats, **cloud_user_stats} class SystemConfigView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): diff --git a/apps/hosts/forms_admin.py b/apps/hosts/forms_admin.py index dc136a3..c82ff2f 100644 --- a/apps/hosts/forms_admin.py +++ b/apps/hosts/forms_admin.py @@ -5,33 +5,50 @@ 包含主机创建/编辑表单和主机组表单。 """ +import os + from django import forms from django.contrib.auth import get_user_model +from django.conf import settings +from utils.provider import PROVIDER_GROUP_NAME from .models import Host, HostGroup +from .forms_wizard import ( + validate_certificate_pem, + validate_private_key_pem, + _ensure_cert_dir, +) User = get_user_model() - -# MD3 输入框样式常量 INPUT_CLASS = ( - 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md ' - 'px-4 py-3 text-md-on-surface placeholder-md-outline ' - 'focus:outline-none focus:ring-2 focus:ring-md-primary transition' + 'w-full bg-slate-900/50 backdrop-blur-sm border border-slate-700/50 ' + 'rounded px-3 py-2 text-slate-200 placeholder-slate-500 ' + 'focus:outline-none focus:ring-1 focus:ring-cyan-500/50 ' + 'focus:border-cyan-500 transition' ) SELECT_CLASS = ( - 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md ' - 'px-4 py-3 text-md-on-surface appearance-none ' - 'focus:outline-none focus:ring-2 focus:ring-md-primary transition cursor-pointer' + 'w-full bg-slate-900/50 backdrop-blur-sm border border-slate-700/50 ' + 'rounded px-3 py-2 text-slate-200 appearance-none ' + 'focus:outline-none focus:ring-1 focus:ring-cyan-500/50 ' + 'focus:border-cyan-500 transition cursor-pointer' ) CHECKBOX_CLASS = ( - 'w-5 h-5 rounded border-md-outline/50 bg-md-surface/50 ' - 'text-md-primary focus:ring-md-primary focus:ring-2 transition accent-md-primary' + 'w-5 h-5 rounded border-slate-700/50 bg-slate-900/50 ' + 'text-cyan-400 focus:ring-cyan-500 focus:ring-2 transition ' + 'accent-cyan-500 cursor-pointer' ) MULTI_SELECT_CLASS = ( - 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md ' - 'px-4 py-3 text-md-on-surface ' - 'focus:outline-none focus:ring-2 focus:ring-md-primary transition min-h-[120px]' + 'w-full bg-slate-900/50 backdrop-blur-sm border border-slate-700/50 ' + 'rounded px-3 py-2 text-slate-200 ' + 'focus:outline-none focus:ring-1 focus:ring-cyan-500/50 ' + 'focus:border-cyan-500 transition min-h-[120px]' +) +FILE_INPUT_CLASS = ( + 'w-full text-sm text-slate-200 file:mr-4 file:py-2 file:px-4 ' + 'file:rounded file:border-0 file:text-sm file:font-medium ' + 'file:bg-cyan-600/20 file:text-cyan-400 hover:file:bg-cyan-600/30 ' + 'file:cursor-pointer cursor-pointer' ) @@ -46,18 +63,38 @@ class AdminHostForm(forms.ModelForm): password = forms.CharField( widget=forms.PasswordInput(attrs={ 'class': INPUT_CLASS, - 'placeholder': '输入密码或留空自动生成', + 'placeholder': '输入远程主机登录密码', 'autocomplete': 'new-password', }), required=False, - help_text='留空将自动生成随机密码', label='密码', ) + cert_pem = forms.FileField( + label='客户端证书(公钥)', + required=False, + widget=forms.ClearableFileInput(attrs={ + 'class': FILE_INPUT_CLASS, + 'accept': '.pem,.cer,.crt,.cert', + }), + help_text='PEM格式的客户端证书文件', + ) + + cert_key = forms.FileField( + label='客户端私钥', + required=False, + widget=forms.ClearableFileInput(attrs={ + 'class': FILE_INPUT_CLASS, + 'accept': '.pem,.key', + }), + help_text='PEM格式的客户端私钥文件', + ) + class Meta: model = Host fields = [ - 'name', 'hostname', 'connection_type', 'port', 'rdp_port', + 'name', 'os_type', 'hostname', 'connection_type', + 'auth_method', 'port', 'rdp_port', 'use_ssl', 'username', 'providers', ] @@ -66,6 +103,9 @@ class Meta: 'class': INPUT_CLASS, 'placeholder': '输入主机名称', }), + 'os_type': forms.Select(attrs={ + 'class': SELECT_CLASS, + }), 'hostname': forms.TextInput(attrs={ 'class': INPUT_CLASS, 'placeholder': '输入主机地址', @@ -73,6 +113,9 @@ class Meta: 'connection_type': forms.Select(attrs={ 'class': SELECT_CLASS, }), + 'auth_method': forms.Select(attrs={ + 'class': SELECT_CLASS, + }), 'port': forms.NumberInput(attrs={ 'class': INPUT_CLASS, 'placeholder': '5985', @@ -95,9 +138,11 @@ class Meta: } labels = { 'name': '主机名称', + 'os_type': '主机系统', 'hostname': '主机地址', 'connection_type': '连接类型', - 'port': '连接端口', + 'auth_method': '连接方式', + 'port': 'WinRM端口', 'rdp_port': 'RDP端口', 'use_ssl': '使用SSL', 'username': '用户名', @@ -111,41 +156,125 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) provider_users = User.objects.filter( - groups__name='提供商', + groups__name=PROVIDER_GROUP_NAME, is_staff=True, is_superuser=False, ).order_by('username') self.fields['providers'].queryset = provider_users - # 编辑模式下密码提示 + self.fields['os_type'].choices = Host.OS_TYPE_CHOICES + self.fields['connection_type'].choices = [ + ('winrm', 'WinRM'), + ('localwinserver', '本地WinServer'), + ] + self.fields['auth_method'].choices = Host.AUTH_METHOD_CHOICES + if self.instance.pk: self.fields['password'].help_text = ( '留空则不修改密码。为安全起见,此处不显示原密码。' ) self.fields['password'].required = False + def clean(self): + cleaned_data = super().clean() + connection_type = cleaned_data.get('connection_type') + auth_method = cleaned_data.get('auth_method') + + if connection_type == 'winrm' and auth_method == 'certificate': + cert_pem = self.files.get('cert_pem') + cert_key = self.files.get('cert_key') + has_existing = ( + self.instance.pk + and self.instance.cert_pem_path + and os.path.exists(self.instance.cert_pem_path) + ) + if not cert_pem and not has_existing: + self.add_error('cert_pem', '证书认证方式必须上传客户端证书') + if not cert_key and not has_existing: + self.add_error('cert_key', '证书认证方式必须上传客户端私钥') + if cert_pem: + try: + validate_certificate_pem(cert_pem.read()) + except forms.ValidationError as e: + self.add_error('cert_pem', e) + finally: + cert_pem.seek(0) + if cert_key: + try: + validate_private_key_pem(cert_key.read()) + except forms.ValidationError as e: + self.add_error('cert_key', e) + finally: + cert_key.seek(0) + + if connection_type == 'winrm' and auth_method == 'ntlm': + if not cleaned_data.get('username'): + self.add_error('username', 'NTLM认证方式必须填写用户名') + if not self.instance.pk and not cleaned_data.get('password'): + self.add_error('password', 'NTLM认证方式必须填写密码') + + if connection_type == 'localwinserver': + if not cleaned_data.get('username'): + self.add_error('username', '必须填写用户名') + if not self.instance.pk and not cleaned_data.get('password'): + self.add_error('password', '必须填写密码') + + return cleaned_data + def save(self, commit=True): instance = super().save(commit=False) + auth_method = self.cleaned_data.get('auth_method') + connection_type = self.cleaned_data.get('connection_type') password = self.cleaned_data.get('password') if self.instance.pk: - # 编辑模式:仅当密码不为空时修改 if password: instance.password = password else: - # 创建模式:密码为空则自动生成 - if password: + if connection_type == 'winrm' and auth_method == 'ntlm': + instance.password = password + elif connection_type == 'winrm' and auth_method == 'certificate': + instance.cert_pem_path = instance.cert_pem_path or '' + instance.cert_key_path = instance.cert_key_path or '' + elif connection_type == 'localwinserver': instance.password = password - else: - from .forms_provider import generate_random_password - self.generated_password = generate_random_password() - instance.password = self.generated_password if commit: instance.save() self.save_m2m() + if connection_type == 'winrm' and auth_method == 'certificate': + self._save_cert_files(instance) + return instance + def _save_cert_files(self, host): + cert_pem = self.files.get('cert_pem') + cert_key = self.files.get('cert_key') + if not cert_pem or not cert_key: + return + + cert_dir = _ensure_cert_dir(host.pk) + pem_path = os.path.join(cert_dir, 'client.pem') + key_path = os.path.join(cert_dir, 'client.key') + + with open(pem_path, 'wb') as f: + for chunk in cert_pem.chunks(): + f.write(chunk) + + with open(key_path, 'wb') as f: + for chunk in cert_key.chunks(): + f.write(chunk) + + os.chmod(pem_path, 0o600) + os.chmod(key_path, 0o600) + + host.cert_pem_path = pem_path + host.cert_key_path = key_path + Host.objects.filter(pk=host.pk).update( + cert_pem_path=pem_path, + cert_key_path=key_path, + ) + class AdminHostGroupForm(forms.ModelForm): """ @@ -191,12 +320,10 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # hosts: 所有主机 self.fields['hosts'].queryset = Host.objects.order_by('name') - # providers: 所有提供商组用户 provider_users = User.objects.filter( - groups__name='提供商', + groups__name=PROVIDER_GROUP_NAME, is_staff=True, is_superuser=False, ).order_by('username') diff --git a/apps/hosts/forms_provider.py b/apps/hosts/forms_provider.py index 00fc45c..000084e 100644 --- a/apps/hosts/forms_provider.py +++ b/apps/hosts/forms_provider.py @@ -246,9 +246,9 @@ def __init__(self, *args, **kwargs): # 过滤 providers:只显示提供商组的用户 from django.contrib.auth.models import User - from utils.provider import is_provider + from utils.provider import is_provider, PROVIDER_GROUP_NAME provider_users = User.objects.filter( - groups__name='提供商', + groups__name=PROVIDER_GROUP_NAME, is_staff=True, is_superuser=False, ).order_by('username') diff --git a/apps/hosts/forms_wizard.py b/apps/hosts/forms_wizard.py index d6102ed..4c5d1ff 100644 --- a/apps/hosts/forms_wizard.py +++ b/apps/hosts/forms_wizard.py @@ -5,72 +5,116 @@ 与 AdminHostForm 不同,此表单专注于创建流程的简化和引导。 """ +import os + from django import forms from django.contrib.auth import get_user_model +from django.conf import settings +from utils.provider import PROVIDER_GROUP_NAME from .models import Host User = get_user_model() - -# MD3 输入框样式常量 INPUT_CLASS = ( - 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md ' - 'px-4 py-3 text-md-on-surface placeholder-md-outline ' - 'focus:outline-none focus:ring-2 focus:ring-md-primary transition' + 'w-full bg-slate-900/50 backdrop-blur-sm border border-slate-700/50 ' + 'rounded px-3 py-2 text-slate-200 placeholder-slate-500 ' + 'focus:outline-none focus:ring-1 focus:ring-cyan-500/50 ' + 'focus:border-cyan-500 transition' ) SELECT_CLASS = ( - 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md ' - 'px-4 py-3 text-md-on-surface appearance-none ' - 'focus:outline-none focus:ring-2 focus:ring-md-primary transition cursor-pointer' + 'w-full bg-slate-900/50 backdrop-blur-sm border border-slate-700/50 ' + 'rounded px-3 py-2 text-slate-200 appearance-none ' + 'focus:outline-none focus:ring-1 focus:ring-cyan-500/50 ' + 'focus:border-cyan-500 transition cursor-pointer' ) CHECKBOX_CLASS = ( - 'w-5 h-5 rounded border-md-outline/50 bg-md-surface/50 ' - 'text-md-primary focus:ring-md-primary focus:ring-2 transition accent-md-primary' + 'w-5 h-5 rounded border-slate-700/50 bg-slate-900/50 ' + 'text-cyan-400 focus:ring-cyan-500 focus:ring-2 transition ' + 'accent-cyan-500 cursor-pointer' +) +FILE_INPUT_CLASS = ( + 'w-full text-sm text-slate-200 file:mr-4 file:py-2 file:px-4 ' + 'file:rounded file:border-0 file:text-sm file:font-medium ' + 'file:bg-cyan-600/20 file:text-cyan-400 hover:file:bg-cyan-600/30 ' + 'file:cursor-pointer cursor-pointer' ) -# 连接类型 -> 默认端口映射 CONNECTION_DEFAULT_PORTS = { 'winrm': 5985, - 'ssh': 22, 'localwinserver': 5985, + 'ssh': 22, 'tunnel': 5985, } -# 连接类型 -> 默认SSL映射 CONNECTION_DEFAULT_SSL = { 'winrm': False, - 'ssh': False, 'localwinserver': False, + 'ssh': False, 'tunnel': False, } +CERT_STORAGE_DIR = os.path.join(settings.MEDIA_ROOT, 'certs', 'hosts') + + +def _ensure_cert_dir(host_pk): + d = os.path.join(CERT_STORAGE_DIR, str(host_pk)) + os.makedirs(d, exist_ok=True) + return d + + +def validate_certificate_pem(content: bytes, field_name: str = '证书') -> None: + try: + text = content.decode('utf-8') + except UnicodeDecodeError: + raise forms.ValidationError(f'{field_name}文件编码无效,必须为UTF-8文本格式') + if '-----BEGIN' not in text: + raise forms.ValidationError(f'{field_name}文件格式无效,不是合法的PEM格式') + if '-----END' not in text: + raise forms.ValidationError(f'{field_name}文件格式无效,不是合法的PEM格式') + + +def validate_private_key_pem(content: bytes) -> None: + try: + text = content.decode('utf-8') + except UnicodeDecodeError: + raise forms.ValidationError('私钥文件编码无效,必须为UTF-8文本格式') + if '-----BEGIN' not in text: + raise forms.ValidationError('私钥文件格式无效,不是合法的PEM格式') + if 'PRIVATE KEY' not in text: + raise forms.ValidationError('私钥文件格式无效,未包含私钥标识') + if '-----END' not in text: + raise forms.ValidationError('私钥文件格式无效,不是合法的PEM格式') + try: + from cryptography.hazmat.primitives.serialization import load_pem_private_key + from cryptography.hazmat.backends import default_backend + load_pem_private_key(content, password=None, backend=default_backend()) + except ImportError: + # cryptography 为可选依赖:缺失时仅执行基础 PEM 文本校验, + # 不阻断表单流程,以保持兼容现有部署环境。 + pass + except Exception as e: + raise forms.ValidationError(f'私钥文件无效: {str(e)}') + class HostWizardForm(forms.ModelForm): """ 主机创建向导表单 分为三步: - - Step 1: 基本信息 (name, hostname, connection_type) - - Step 2: 连接配置 (port, rdp_port, use_ssl, username, password) 或 执行命令 (隧道模式) + - Step 1: 基本信息 (name, os_type, hostname, connection_type) + - Step 2: 连接配置 (port, auth_method, username/password 或 证书) - Step 3: 分配提供商 (providers, description) - - 智能默认值: - - port 根据连接类型自动设置 (winrm=5985, ssh=22) - - use_ssl 根据端口自动判断 (5986=True) - - 密码留空时自动生成 - - 隧道模式下hostname非必填,自动生成 """ password = forms.CharField( widget=forms.PasswordInput(attrs={ 'class': INPUT_CLASS, - 'placeholder': '输入密码或留空自动生成', + 'placeholder': '输入远程主机登录密码', 'autocomplete': 'new-password', 'x-model': 'password', }), required=False, - help_text='留空将自动生成随机密码', label='密码', ) @@ -81,11 +125,47 @@ class HostWizardForm(forms.ModelForm): required=False, ) + init_token = forms.CharField( + widget=forms.HiddenInput(attrs={ + 'x-model': 'initToken', + }), + required=False, + ) + + cert_config_method = forms.CharField( + widget=forms.HiddenInput(attrs={ + 'x-model': 'certConfigMethod', + }), + required=False, + initial='quick', + ) + + cert_pem = forms.FileField( + label='客户端证书(公钥)', + required=False, + widget=forms.ClearableFileInput(attrs={ + 'class': FILE_INPUT_CLASS, + 'accept': '.pem,.cer,.crt,.cert', + }), + help_text='PEM格式的客户端证书文件', + ) + + cert_key = forms.FileField( + label='客户端私钥', + required=False, + widget=forms.ClearableFileInput(attrs={ + 'class': FILE_INPUT_CLASS, + 'accept': '.pem,.key', + }), + help_text='PEM格式的客户端私钥文件', + ) + class Meta: model = Host fields = [ - 'name', 'hostname', 'connection_type', - 'port', 'rdp_port', 'use_ssl', 'username', 'password', + 'name', 'os_type', 'hostname', 'connection_type', + 'auth_method', 'port', 'rdp_port', 'use_ssl', + 'username', 'password', 'providers', 'description', 'tunnel_token', ] @@ -95,6 +175,10 @@ class Meta: 'placeholder': '输入主机名称,如: 北京服务器-01', 'x-model': 'name', }), + 'os_type': forms.Select(attrs={ + 'class': SELECT_CLASS, + 'x-model': 'osType', + }), 'hostname': forms.TextInput(attrs={ 'class': INPUT_CLASS, 'placeholder': '输入主机地址,如: 192.168.1.100', @@ -105,6 +189,11 @@ class Meta: 'x-model': 'connectionType', 'x-on:change': 'onConnectionTypeChange()', }), + 'auth_method': forms.Select(attrs={ + 'class': SELECT_CLASS, + 'x-model': 'authMethod', + 'x-on:change': 'onAuthMethodChange()', + }), 'port': forms.NumberInput(attrs={ 'class': INPUT_CLASS, 'placeholder': '5985', @@ -134,9 +223,11 @@ class Meta: } labels = { 'name': '主机名称', + 'os_type': '主机系统', 'hostname': '主机地址', 'connection_type': '连接类型', - 'port': '连接端口', + 'auth_method': '连接方式', + 'port': 'WinRM端口', 'rdp_port': 'RDP端口', 'use_ssl': '使用SSL', 'username': '用户名', @@ -148,7 +239,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) provider_users = User.objects.filter( - groups__name='提供商', + groups__name=PROVIDER_GROUP_NAME, is_staff=True, is_superuser=False, ).order_by('username') @@ -159,10 +250,20 @@ def __init__(self, *args, **kwargs): if not self.initial.get('rdp_port'): self.initial['rdp_port'] = 3389 + self.fields['os_type'].choices = Host.OS_TYPE_CHOICES + + self.fields['connection_type'].choices = [ + ('winrm', 'WinRM'), + ('localwinserver', '本地WinServer'), + ] + + self.fields['auth_method'].choices = Host.AUTH_METHOD_CHOICES + def clean(self): cleaned_data = super().clean() connection_type = cleaned_data.get('connection_type') hostname = cleaned_data.get('hostname') + auth_method = cleaned_data.get('auth_method') if connection_type == 'tunnel' and not hostname: cleaned_data['hostname'] = 'tunnel-pending' @@ -171,30 +272,94 @@ def clean(self): if tunnel_token == '': cleaned_data['tunnel_token'] = None + if connection_type == 'winrm' and auth_method == 'certificate': + cert_config_method = cleaned_data.get('cert_config_method', 'quick') + cert_pem = self.files.get('cert_pem') + cert_key = self.files.get('cert_key') + if cert_config_method == 'manual': + if not cert_pem: + self.add_error('cert_pem', '证书认证方式必须上传客户端证书') + if not cert_key: + self.add_error('cert_key', '证书认证方式必须上传客户端私钥') + if cert_pem: + try: + validate_certificate_pem(cert_pem.read()) + except forms.ValidationError as e: + self.add_error('cert_pem', e) + finally: + cert_pem.seek(0) + if cert_key: + try: + validate_private_key_pem(cert_key.read()) + except forms.ValidationError as e: + self.add_error('cert_key', e) + finally: + cert_key.seek(0) + + if connection_type == 'winrm' and auth_method == 'ntlm': + if not cleaned_data.get('username'): + self.add_error('username', 'NTLM认证方式必须填写用户名') + if not cleaned_data.get('password'): + self.add_error('password', 'NTLM认证方式必须填写密码') + + if connection_type == 'localwinserver': + if not cleaned_data.get('username'): + self.add_error('username', '必须填写用户名') + if not cleaned_data.get('password'): + self.add_error('password', '必须填写密码') + return cleaned_data def save(self, commit=True): instance = super().save(commit=False) - password = self.cleaned_data.get('password') + auth_method = self.cleaned_data.get('auth_method') + connection_type = self.cleaned_data.get('connection_type') - # 创建模式:密码为空则自动生成 - if password: - instance.password = password - else: - from .forms_provider import generate_random_password - self.generated_password = generate_random_password() - instance.password = self.generated_password + if connection_type == 'winrm' and auth_method == 'ntlm': + instance.password = self.cleaned_data.get('password') + elif connection_type == 'winrm' and auth_method == 'certificate': + instance.cert_pem_path = '' + instance.cert_key_path = '' + elif connection_type == 'localwinserver': + instance.password = self.cleaned_data.get('password') if commit: instance.save() self.save_m2m() + if connection_type == 'winrm' and auth_method == 'certificate': + self._save_cert_files(instance) + return instance + def _save_cert_files(self, host): + cert_pem = self.files.get('cert_pem') + cert_key = self.files.get('cert_key') + if not cert_pem or not cert_key: + return + + cert_dir = _ensure_cert_dir(host.pk) + pem_path = os.path.join(cert_dir, 'client.pem') + key_path = os.path.join(cert_dir, 'client.key') + + with open(pem_path, 'wb') as f: + for chunk in cert_pem.chunks(): + f.write(chunk) + + with open(key_path, 'wb') as f: + for chunk in cert_key.chunks(): + f.write(chunk) + + os.chmod(pem_path, 0o600) + os.chmod(key_path, 0o600) + + host.cert_pem_path = pem_path + host.cert_key_path = key_path + Host.objects.filter(pk=host.pk).update( + cert_pem_path=pem_path, + cert_key_path=key_path, + ) + def get_providers_with_host_count(self): - """ - 返回提供商列表及其当前管理的主机数量, - 用于向导第三步的上下文展示。 - """ providers = self.fields['providers'].queryset result = [] for provider in providers: diff --git a/apps/hosts/migrations/0011_host_auth_method_host_cert_key_path_and_more.py b/apps/hosts/migrations/0011_host_auth_method_host_cert_key_path_and_more.py new file mode 100644 index 0000000..15da766 --- /dev/null +++ b/apps/hosts/migrations/0011_host_auth_method_host_cert_key_path_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.30 on 2026-05-23 05:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hosts', '0010_remove_host_host_type'), + ] + + operations = [ + migrations.AddField( + model_name='host', + name='auth_method', + field=models.CharField(choices=[('ntlm', '管理员账户密码'), ('certificate', '证书')], default='ntlm', max_length=20, verbose_name='连接方式'), + ), + migrations.AddField( + model_name='host', + name='cert_key_path', + field=models.CharField(blank=True, default='', max_length=512, verbose_name='客户端私钥路径'), + ), + migrations.AddField( + model_name='host', + name='cert_pem_path', + field=models.CharField(blank=True, default='', max_length=512, verbose_name='客户端证书路径'), + ), + migrations.AddField( + model_name='host', + name='os_type', + field=models.CharField(choices=[('windows', 'Windows')], default='windows', max_length=20, verbose_name='主机系统'), + ), + migrations.AlterField( + model_name='host', + name='connection_type', + field=models.CharField(choices=[('winrm', 'WinRM'), ('localwinserver', '本地WinServer'), ('ssh', 'SSH'), ('tunnel', '隧道模式(零公网IP)')], default='winrm', max_length=20, verbose_name='连接类型'), + ), + ] diff --git a/apps/hosts/migrations/0012_host_username_optional.py b/apps/hosts/migrations/0012_host_username_optional.py new file mode 100644 index 0000000..13e7c07 --- /dev/null +++ b/apps/hosts/migrations/0012_host_username_optional.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.30 on 2026-05-23 09:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hosts', '0011_host_auth_method_host_cert_key_path_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='host', + name='username', + field=models.CharField(blank=True, default='', max_length=100, verbose_name='用户名'), + ), + ] diff --git a/apps/hosts/migrations/0013_add_cert_provision_fields.py b/apps/hosts/migrations/0013_add_cert_provision_fields.py new file mode 100644 index 0000000..6da3cac --- /dev/null +++ b/apps/hosts/migrations/0013_add_cert_provision_fields.py @@ -0,0 +1,48 @@ +# Generated by Django 4.2.30 on 2026-05-29 15:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hosts', '0012_host_username_optional'), + ] + + operations = [ + migrations.AddField( + model_name='host', + name='_ntlm_fallback_password', + field=models.CharField(blank=True, db_column='ntlm_fallback_password', default='', max_length=255, verbose_name='NTLM回退密码'), + ), + migrations.AddField( + model_name='host', + name='_pfx_password', + field=models.CharField(blank=True, db_column='pfx_password', default='', max_length=255, verbose_name='PFX密码'), + ), + migrations.AddField( + model_name='host', + name='cert_activated_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='证书激活时间'), + ), + migrations.AddField( + model_name='host', + name='cert_provision_status', + field=models.CharField(choices=[('not_started', '未开始'), ('pending', '签发中'), ('ready', '已就绪'), ('configured', '已配置'), ('failed', '失败')], default='not_started', max_length=20, verbose_name='证书配置状态'), + ), + migrations.AddField( + model_name='host', + name='cert_root', + field=models.CharField(blank=True, default='', max_length=2, verbose_name='证书存储根路径'), + ), + migrations.AddField( + model_name='host', + name='cert_sub', + field=models.CharField(blank=True, default='', max_length=2, verbose_name='证书存储子路径'), + ), + migrations.AddField( + model_name='host', + name='ntlm_fallback_user', + field=models.CharField(blank=True, default='', max_length=100, verbose_name='NTLM回退用户名'), + ), + ] diff --git a/apps/hosts/models.py b/apps/hosts/models.py index c045a81..7cfb25c 100755 --- a/apps/hosts/models.py +++ b/apps/hosts/models.py @@ -1,5 +1,7 @@ from django.db import models from django.conf import settings +from utils.crypto import encrypt_value, decrypt_value +import logging import os @@ -7,13 +9,22 @@ class Host(models.Model): """ 主机模型 """ + OS_TYPE_CHOICES = [ + ('windows', 'Windows'), + ] + CONNECTION_TYPE_CHOICES = [ ('winrm', 'WinRM'), - ('ssh', 'SSH'), ('localwinserver', '本地WinServer'), + ('ssh', 'SSH'), ('tunnel', '隧道模式(零公网IP)'), ] - + + AUTH_METHOD_CHOICES = [ + ('ntlm', '管理员账户密码'), + ('certificate', '证书'), + ] + STATUS_CHOICES = [ ('online', '在线'), ('offline', '离线'), @@ -27,14 +38,26 @@ class Host(models.Model): ('error', '隧道错误'), ] + CERT_PROVISION_STATUS_CHOICES = [ + ('not_started', '未开始'), + ('pending', '签发中'), + ('ready', '已就绪'), + ('configured', '已配置'), + ('failed', '失败'), + ] + name = models.CharField(max_length=100, verbose_name='主机名称') + os_type = models.CharField(max_length=20, choices=OS_TYPE_CHOICES, default='windows', verbose_name='主机系统') hostname = models.CharField(max_length=255, verbose_name='主机地址') connection_type = models.CharField(max_length=20, choices=CONNECTION_TYPE_CHOICES, default='winrm', verbose_name='连接类型') + auth_method = models.CharField(max_length=20, choices=AUTH_METHOD_CHOICES, default='ntlm', verbose_name='连接方式') port = models.IntegerField(default=5985, verbose_name='连接端口') rdp_port = models.IntegerField(default=3389, verbose_name='RDP端口') use_ssl = models.BooleanField(default=False, verbose_name='使用SSL') - username = models.CharField(max_length=100, verbose_name='用户名') + username = models.CharField(max_length=100, blank=True, default='', verbose_name='用户名') _password = models.CharField(max_length=255, verbose_name='密码', db_column='password') # 加密存储 + cert_pem_path = models.CharField(max_length=512, blank=True, default='', verbose_name='客户端证书路径') + cert_key_path = models.CharField(max_length=512, blank=True, default='', verbose_name='客户端私钥路径') os_version = models.CharField(max_length=100, blank=True, verbose_name='操作系统版本') status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='offline', verbose_name='状态') description = models.TextField(blank=True, verbose_name='描述') @@ -84,6 +107,37 @@ class Host(models.Model): blank=True, verbose_name='隧道公钥(Ed25519)' ) + cert_root = models.CharField( + max_length=2, blank=True, default='', + verbose_name='证书存储根路径' + ) + cert_sub = models.CharField( + max_length=2, blank=True, default='', + verbose_name='证书存储子路径' + ) + _pfx_password = models.CharField( + max_length=255, blank=True, default='', + db_column='pfx_password', verbose_name='PFX密码' + ) + ntlm_fallback_user = models.CharField( + max_length=100, blank=True, default='', + verbose_name='NTLM回退用户名' + ) + _ntlm_fallback_password = models.CharField( + max_length=255, blank=True, default='', + db_column='ntlm_fallback_password', + verbose_name='NTLM回退密码' + ) + cert_activated_at = models.DateTimeField( + null=True, blank=True, verbose_name='证书激活时间' + ) + cert_provision_status = models.CharField( + max_length=20, + choices=CERT_PROVISION_STATUS_CHOICES, + default='not_started', + verbose_name='证书配置状态' + ) + class Meta: verbose_name = '主机' verbose_name_plural = '主机' @@ -94,26 +148,36 @@ def __str__(self): @property def password(self): - from cryptography.fernet import Fernet - import base64 - import hashlib - from django.conf import settings - key = hashlib.sha256(settings.SECRET_KEY.encode()).digest() - f = Fernet(base64.urlsafe_b64encode(key)) try: - return f.decrypt(self._password.encode()).decode() - except Exception: + return decrypt_value(self._password) + except ValueError: raise ValueError("密码解密失败,数据可能已损坏或密钥已变更") @password.setter def password(self, raw_password): - from cryptography.fernet import Fernet - import base64 - import hashlib - from django.conf import settings - key = hashlib.sha256(settings.SECRET_KEY.encode()).digest() - f = Fernet(base64.urlsafe_b64encode(key)) - self._password = f.encrypt(raw_password.encode()).decode() + self._password = encrypt_value(raw_password) + + @property + def pfx_password(self): + try: + return decrypt_value(self._pfx_password) + except ValueError: + raise ValueError("PFX密码解密失败") + + @pfx_password.setter + def pfx_password(self, raw_password): + self._pfx_password = encrypt_value(raw_password) + + @property + def ntlm_fallback_password(self): + try: + return decrypt_value(self._ntlm_fallback_password) + except ValueError: + raise ValueError("NTLM回退密码解密失败") + + @ntlm_fallback_password.setter + def ntlm_fallback_password(self, raw_password): + self._ntlm_fallback_password = encrypt_value(raw_password) def save(self, *args, **kwargs): """ @@ -127,13 +191,20 @@ def save(self, *args, **kwargs): def get_connection_client(self): if self.connection_type == 'winrm': from utils.winrm_client import WinrmClient - return WinrmClient( + kwargs = dict( hostname=self.hostname, - username=self.username, - password=self.password, port=self.port, - use_ssl=self.use_ssl + use_ssl=self.use_ssl, ) + if self.auth_method == 'certificate': + return FallbackWinrmClient(self) + else: + kwargs.update( + username=self.username, + password=self.password, + auth_method='ntlm', + ) + return WinrmClient(**kwargs) elif self.connection_type == 'localwinserver': from utils.local_winserver_client import LocalWinServerClient return LocalWinServerClient( @@ -159,6 +230,16 @@ def test_connection(self): new_status = 'online' if self.tunnel_status == 'online' else 'offline' Host.objects.filter(pk=self.pk).update(status=new_status) return + + if (self.auth_method == 'certificate' + and (not self.cert_pem_path + or not os.path.exists(self.cert_pem_path))): + logging.getLogger("2c2a").warning( + f"证书文件不存在,跳过连接测试: {self.name} " + f"(pem={self.cert_pem_path})" + ) + Host.objects.filter(pk=self.pk).update(status='pending') + return try: client = self.get_connection_client() @@ -177,7 +258,6 @@ def test_connection(self): except Exception as e: new_status = 'error' - import logging logger = logging.getLogger("2c2a") logger.error( f"测试主机连接失败: {self.name}, 错误: {str(e)}" @@ -318,6 +398,148 @@ def add_to_remote_users(self, username): return self.execute_powershell(script) +class FallbackWinrmClient: + _logger = logging.getLogger("2c2a") + + def __init__(self, host): + self.host = host + self._client = None + + def _try_connect(self): + if self._client is not None: + return + from utils.winrm_client import WinrmClient + from utils.cert_storage import get_cert_file_paths + last_exc = None + ca_trust_path = None + if self.host.cert_root and self.host.cert_sub: + paths = get_cert_file_paths(self.host.cert_root, self.host.cert_sub) + if paths['ca_cert'].exists(): + ca_trust_path = str(paths['ca_cert']) + configs = [ + ( + "SSL+Certificate", + dict( + hostname=self.host.hostname, + port=self.host.port, + use_ssl=True, + auth_method='certificate', + cert_pem_path=self.host.cert_pem_path, + cert_key_path=self.host.cert_key_path, + server_cert_validation='ignore', + ca_trust_path=ca_trust_path, + ), + ), + ] + if self.host.ntlm_fallback_user and self.host.ntlm_fallback_password: + configs.append(( + "HTTPS+NTLM", + dict( + hostname=self.host.hostname, + port=self.host.port, + use_ssl=True, + auth_method='ntlm', + username=self.host.ntlm_fallback_user, + password=self.host.ntlm_fallback_password, + server_cert_validation='ignore', + ca_trust_path=ca_trust_path, + ), + )) + configs.append(( + "HTTP+NTLM", + dict( + hostname=self.host.hostname, + port=5985, + use_ssl=False, + auth_method='ntlm', + username=self.host.ntlm_fallback_user, + password=self.host.ntlm_fallback_password, + ), + )) + for label, cfg in configs: + try: + client = WinrmClient(**cfg) + client.execute_command('whoami') + self._client = client + self._logger.info( + f"主机 {self.host.name} 连接成功," + f"使用方式: {label}" + ) + return + except Exception as e: + last_exc = e + self._logger.warning( + f"主机 {self.host.name} 连接方式 {label} " + f"失败: {e}" + ) + Host.objects.filter(pk=self.host.pk).update(status='error') + if last_exc is not None: + raise last_exc + raise RuntimeError("所有连接方式均失败") + + @property + def success(self): + return True + + def execute_command(self, command): + self._try_connect() + return self._client.execute_command(command) + + def execute_powershell(self, script): + self._try_connect() + return self._client.execute_powershell(script) + + def create_user(self, username, password, **kwargs): + self._try_connect() + return self._client.create_user(username, password, **kwargs) + + def delete_user(self, username): + self._try_connect() + return self._client.delete_user(username) + + def enable_user(self, username): + self._try_connect() + return self._client.enable_user(username) + + def disabled_user(self, username): + self._try_connect() + return self._client.disabled_user(username) + + def reset_password(self, username, password): + self._try_connect() + return self._client.reset_password(username, password) + + def op_user(self, username): + self._try_connect() + return self._client.op_user(username) + + def deop_user(self, username): + self._try_connect() + return self._client.deop_user(username) + + def add_to_remote_users(self, username): + self._try_connect() + return self._client.add_to_remote_users(username) + + def check_user_exists(self, username): + self._try_connect() + return self._client.check_user_exists(username) + + def generate_strong_password(self, length=None): + self._try_connect() + return self._client.generate_strong_password(length) + + def get_password_policy(self): + self._try_connect() + return self._client.get_password_policy() + + def create_user_with_reset_password_on_next_login( + self, username, password, description=None, group=None): + self._try_connect() + return self._client.create_user_with_reset_password_on_next_login( + username, password, description=description, group=group) + + class HostGroup(models.Model): """ 主机组模型 diff --git a/apps/hosts/tasks.py b/apps/hosts/tasks.py index cc6c06e..7d4a274 100755 --- a/apps/hosts/tasks.py +++ b/apps/hosts/tasks.py @@ -1,8 +1,9 @@ from celery import shared_task from django.contrib.auth.models import User +from django.utils import timezone + from apps.hosts.models import Host from apps.tasks.models import AsyncTask -from apps.certificates.models import ServerCertificate, ClientCertificate import logging import re @@ -163,61 +164,43 @@ def test_winrm_connection(self, host_id, use_certificate_auth=False): host = Host.objects.get(id=host_id) task.start_execution() - if use_certificate_auth and host.certificate_thumbprint: - import tempfile + if use_certificate_auth and host.auth_method == 'certificate': import os - - client_cert = ClientCertificate.objects.filter(is_active=True).first() - if not client_cert: - from apps.certificates.models import CertificateAuthority - ca = host.get_ca() if hasattr(host, 'get_ca') else None - if not ca: - ca, _ = CertificateAuthority.objects.get_or_create( - name='default-ca', - defaults={'name': 'default-ca', 'description': 'Default Certificate Authority'} - ) - if not ca.certificate: - ca.generate_self_signed_cert() - ca.save() - - client_cert = ClientCertificate( - name=f'client-{host.hostname}', - ca=ca + + cert_pem_path = host.cert_pem_path + cert_key_path = host.cert_key_path + + if not cert_pem_path or not cert_key_path: + raise ValueError( + "证书路径未配置,无法进行证书认证测试" ) - client_cert.generate_client_cert(f'client-{host.hostname}') - client_cert.save() - - with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.pem') as cert_file: - cert_file.write(client_cert.certificate) - cert_file_path = cert_file.name - - with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.pem') as key_file: - key_file.write(client_cert.private_key) - key_file_path = key_file.name - - try: - from utils.winrm_client import WinrmClient - client = WinrmClient( - hostname=host.hostname or host.ip_address, - port=5986, - username='', - password='', - use_ssl=True, - server_cert_validation='validate', - client_cert_pem=cert_file_path, - client_cert_key=key_file_path + if not os.path.exists(cert_pem_path): + raise ValueError( + f"客户端证书文件不存在: {cert_pem_path}" ) - - result = client.execute_command('echo', ['Connection Test']) - success = result.status_code == 0 - - finally: - os.unlink(cert_file_path) - os.unlink(key_file_path) + if not os.path.exists(cert_key_path): + raise ValueError( + f"客户端私钥文件不存在: {cert_key_path}" + ) + + from utils.winrm_client import WinrmClient + client = WinrmClient( + hostname=host.hostname, + port=host.port, + username='', + password='', + use_ssl=True, + auth_method='certificate', + cert_pem_path=cert_pem_path, + cert_key_path=cert_key_path, + server_cert_validation='ignore', + ) + result = client.execute_command('echo', ['Connection Test']) + success = result.status_code == 0 else: from utils.winrm_client import WinrmClient client = WinrmClient( - hostname=host.hostname or host.ip_address, + hostname=host.hostname, port=host.port, username=host.username, password=host.password, @@ -228,6 +211,11 @@ def test_winrm_connection(self, host_id, use_certificate_auth=False): success = result.status_code == 0 if success: + if host.auth_method == 'certificate' and host.cert_provision_status in ('pending', 'ready'): + Host.objects.filter(pk=host.pk).update( + cert_provision_status='configured', + cert_activated_at=timezone.now(), + ) task.progress = 100 task.complete_success({ 'connected': True, @@ -241,6 +229,8 @@ def test_winrm_connection(self, host_id, use_certificate_auth=False): 'protocol': 'HTTPS with Certificate' if use_certificate_auth else 'HTTP with Basic Auth' } else: + if host.auth_method == 'certificate' and host.cert_provision_status in ('pending', 'ready'): + Host.objects.filter(pk=host.pk).update(cert_provision_status='failed') task.complete_failure("Connection test failed") return { 'success': False, @@ -250,6 +240,15 @@ def test_winrm_connection(self, host_id, use_certificate_auth=False): except Exception as e: logger.error(f"测试WinRM连接失败: {str(e)}", exc_info=True) + try: + host = Host.objects.get(id=host_id) + if host.auth_method == 'certificate' and host.cert_provision_status in ('pending', 'ready'): + Host.objects.filter(pk=host.pk).update(cert_provision_status='failed') + except Host.DoesNotExist: + logger.warning( + "测试WinRM连接失败后的清理阶段:主机 #%s 不存在,跳过证书状态更新", + host_id + ) task.complete_failure(str(e)) return { diff --git a/apps/hosts/urls_admin.py b/apps/hosts/urls_admin.py index 8bbac5f..8b43a45 100644 --- a/apps/hosts/urls_admin.py +++ b/apps/hosts/urls_admin.py @@ -28,6 +28,16 @@ views_admin.admin_host_wizard_generate_token, name='host_wizard_generate_token' ), + path( + 'wizard/test-connection/', + views_admin.admin_host_wizard_test_connection, + name='host_wizard_test_connection' + ), + path( + 'wizard/generate-init-command/', + views_admin.admin_host_wizard_generate_init_command, + name='host_wizard_generate_init_command' + ), path( 'create/', views_admin.AdminHostCreateView.as_view(), @@ -53,6 +63,16 @@ views_admin.admin_host_test_connection, name='host_test' ), + path( + '/generate-init-command/', + views_admin.admin_host_generate_init_command, + name='host_generate_init_command' + ), + path( + 'generate-cert-command/', + views_admin.admin_host_generate_cert_command, + name='admin_host_generate_cert_command' + ), # 主机组管理 path( diff --git a/apps/hosts/views_admin.py b/apps/hosts/views_admin.py index e430ffc..8ce100b 100644 --- a/apps/hosts/views_admin.py +++ b/apps/hosts/views_admin.py @@ -8,7 +8,9 @@ import json import logging import os +import platform import secrets +from datetime import timedelta from django.conf import settings from django.contrib import messages @@ -18,12 +20,15 @@ from django.db.models import Q from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect, render +from django.utils import timezone from django.utils.decorators import method_decorator from django.views import View from django.views.generic import DetailView, TemplateView +from django.contrib.auth.decorators import login_required + from apps.accounts.provider_decorators import admin_required -from utils.provider import get_provider_hosts +from utils.provider import get_provider_hosts, PROVIDER_GROUP_NAME from .forms_admin import AdminHostForm, AdminHostGroupForm from .forms_wizard import HostWizardForm, CONNECTION_DEFAULT_PORTS, CONNECTION_DEFAULT_SSL @@ -33,9 +38,72 @@ logger = logging.getLogger(__name__) +def _is_local_winserver(): + return platform.system() == 'Windows' and 'server' in platform.version().lower() + + +def _generate_init_command_data(request, host): + from apps.bootstrap.models import InitialToken + import base64 + + token = secrets.token_urlsafe(32) + expires_at = timezone.now() + timedelta(hours=24) + + initial_token = InitialToken.objects.create( + token=token, + host=host, + expires_at=expires_at, + status='ISSUED', + ) + + pairing_code = initial_token.generate_pairing_code() + + config_data = { + 'c_side_url': request.build_absolute_uri( + '/' + ).rstrip('/'), + 'token': initial_token.token, + 'host_id': str(host.id), + 'expires_at': initial_token.expires_at.isoformat(), + } + config_json = json.dumps(config_data) + encoded_config = base64.b64encode( + config_json.encode('utf-8') + ).decode('utf-8') + + one_liner = ( + "& ([ScriptBlock]::Create(" + "(irm https://static.2c2a.cc.cd/hostinitbash.ps1)" + f")) -Secret '{encoded_config}'" + ) + + fallback_command = ( + "$e = \"$env:TEMP\\h_side_init.exe\"; " + "irm https://2c2a.cc.cd/hostinitbash.exe " + f"-OutFile $e; & $e '{encoded_config}'" + ) + + return { + 'pairing_code': pairing_code, + 'one_liner': one_liner, + 'fallback_command': fallback_command, + 'expires_at': initial_token.expires_at.isoformat(), + 'host_id': host.id, + 'hostname': host.hostname, + } + + +def _get_host_form_context(): + return { + 'default_ports': json.dumps(CONNECTION_DEFAULT_PORTS), + 'default_ssl': json.dumps(CONNECTION_DEFAULT_SSL), + 'is_local_winserver': json.dumps(_is_local_winserver()), + } + + def _get_permission_context(form, host=None): provider_users = User.objects.filter( - groups__name='提供商', + groups__name=PROVIDER_GROUP_NAME, is_staff=True, is_superuser=False, ).order_by('username') @@ -52,7 +120,7 @@ def _get_permission_context(form, host=None): 'name': g.name, 'member_ids': list( g.user_set.filter( - groups__name='提供商', + groups__name=PROVIDER_GROUP_NAME, is_staff=True, is_superuser=False, ) @@ -208,7 +276,6 @@ def get_context_data(self, **kwargs): generated_password = self.request.session.get( 'generated_password' ) - # 一次性读取后清除 self.request.session.pop('generated_password', None) self.request.session.pop( 'generated_password_host_id', None @@ -245,15 +312,71 @@ def get_context_data(self, **kwargs): 'is_create': True, }) context.update(_get_permission_context(form)) + context.update(_get_host_form_context()) return context def post(self, request, *args, **kwargs): - form = AdminHostForm(request.POST) + form = AdminHostForm( + request.POST, request.FILES + ) if form.is_valid(): - host = form.save(commit=False) - host.created_by = request.user - host.save() - form.save_m2m() + init_token_value = form.cleaned_data.get('init_token', '') + logger.info(f"Wizard save: init_token={'yes' if init_token_value else 'no'}, value={init_token_value[:8] if init_token_value else 'N/A'}") + existing_host = None + cert_token_obj = None + if init_token_value: + from apps.bootstrap.models import CertProvisionToken + try: + cert_token_obj = CertProvisionToken.objects.get(token=init_token_value) + if cert_token_obj.host: + existing_host = cert_token_obj.host + except CertProvisionToken.DoesNotExist: + pass + + if existing_host: + for field in ['name', 'os_type', 'hostname', 'connection_type', + 'auth_method', 'port', 'rdp_port', 'use_ssl', + 'username', 'description']: + if field in form.cleaned_data: + setattr(existing_host, field, form.cleaned_data[field]) + pwd = form.cleaned_data.get('password', '') + if pwd: + existing_host.password = pwd + existing_host.save() + form.instance = existing_host + form.save_m2m() + # 保存证书文件 + if existing_host.auth_method == 'certificate': + form._save_cert_files(existing_host) + host = existing_host + else: + host = form.save(commit=False) + host.created_by = request.user + host.save() + form.save_m2m() + # 保存证书文件 + if host.auth_method == 'certificate': + form._save_cert_files(host) + + if cert_token_obj and not existing_host: + cert_token_obj.host = host + if cert_token_obj.cert_data: + cd = cert_token_obj.cert_data + host.cert_root = cd.get('cert_root', '') + host.cert_sub = cd.get('cert_sub', '') + host.pfx_password = cd.get('pfx_password', '') + host.ntlm_fallback_user = cd.get('ntlm_user', '') + host.ntlm_fallback_password = cd.get('ntlm_password', '') + host.cert_provision_status = 'ready' + # 根据 cert_root 和 cert_sub 计算证书路径 + if host.cert_root and host.cert_sub: + from utils.cert_storage import get_cert_dir + cert_dir = get_cert_dir(host.cert_root, host.cert_sub) + host.cert_pem_path = str(cert_dir / 'client.crt') + host.cert_key_path = str(cert_dir / 'client.key') + host.save() + cert_token_obj.cert_data = None + cert_token_obj.save() # 测试连接 try: @@ -281,7 +404,6 @@ def post(self, request, *args, **kwargs): f'已为主机 {host.name} 自动生成密码,' f'请妥善保存。' ) - # 将生成的密码存入 session 以便在详情页展示 request.session['generated_password'] = ( form.generated_password ) @@ -289,6 +411,18 @@ def post(self, request, *args, **kwargs): host.pk ) + init_token = request.POST.get('init_token') + if (host.auth_method == 'certificate' + and init_token and not cert_token_obj): + try: + from apps.bootstrap.models import CertProvisionToken + CertProvisionToken.objects.filter( + token=init_token, + host__isnull=True, + ).update(host=host) + except Exception: + pass + return redirect('admin:admin_hosts:host_detail', pk=host.pk) return self.render_to_response( @@ -329,11 +463,14 @@ def get_context_data(self, **kwargs): 'is_create': False, }) context.update(_get_permission_context(form, host)) + context.update(_get_host_form_context()) return context def post(self, request, *args, **kwargs): host = self.get_host() - form = AdminHostForm(request.POST, instance=host) + form = AdminHostForm( + request.POST, request.FILES, instance=host + ) if form.is_valid(): host = form.save() @@ -689,24 +826,65 @@ def admin_host_wizard(request): 最终一次性提交表单创建主机。 """ if request.method == 'POST': - form = HostWizardForm(request.POST) + form = HostWizardForm( + request.POST, request.FILES + ) if form.is_valid(): host = form.save(commit=False) host.created_by = request.user host.save() form.save_m2m() + # 保存证书文件 + if host.auth_method == 'certificate': + form._save_cert_files(host) + + init_token_value = form.cleaned_data.get('init_token', '') + if init_token_value: + from apps.bootstrap.models import CertProvisionToken + try: + cert_token_obj = CertProvisionToken.objects.get(token=init_token_value) + if not cert_token_obj.host_id: + cert_token_obj.host = host + if cert_token_obj.cert_data: + cd = cert_token_obj.cert_data + host.cert_root = cd.get('cert_root', '') + host.cert_sub = cd.get('cert_sub', '') + host.pfx_password = cd.get('pfx_password', '') + host.ntlm_fallback_user = cd.get('ntlm_user', '') + host.ntlm_fallback_password = cd.get('ntlm_password', '') + host.cert_provision_status = 'ready' + # 根据 cert_root 和 cert_sub 计算证书路径 + if host.cert_root and host.cert_sub: + from utils.cert_storage import get_cert_dir + cert_dir = get_cert_dir(host.cert_root, host.cert_sub) + host.cert_pem_path = str(cert_dir / 'client.crt') + host.cert_key_path = str(cert_dir / 'client.key') + host.save() + cert_token_obj.cert_data = None + cert_token_obj.save() + host.refresh_from_db() + except CertProvisionToken.DoesNotExist: + pass - # 测试连接 try: - host.test_connection() - status_display = dict(Host.STATUS_CHOICES).get( - host.status, host.status - ) - messages.success( - request, - f'主机 {host.name} 创建成功,' - f'状态: {status_display}' - ) + if host.auth_method == 'certificate': + from apps.hosts.tasks import test_winrm_connection + test_winrm_connection.delay(host.pk, use_certificate_auth=True) + messages.success( + request, + f'主机 {host.name} 创建成功,' + f'证书连接测试正在后台执行' + ) + else: + host.test_connection() + status_display = dict(Host.STATUS_CHOICES).get( + host.status, host.status + ) + messages.success( + request, + f'主机 {host.name} 创建成功,' + f'状态: {status_display}' + ) except Exception as e: messages.warning( request, @@ -749,9 +927,15 @@ def admin_host_wizard(request): context = { 'form': form, 'providers_with_count': providers_with_count, - 'connection_type_choices': Host.CONNECTION_TYPE_CHOICES, + 'connection_type_choices': [ + c for c in Host.CONNECTION_TYPE_CHOICES + if c[0] in ('winrm', 'localwinserver') + ], + 'os_type_choices': Host.OS_TYPE_CHOICES, + 'auth_method_choices': Host.AUTH_METHOD_CHOICES, 'default_ports': json.dumps(CONNECTION_DEFAULT_PORTS), 'default_ssl': json.dumps(CONNECTION_DEFAULT_SSL), + 'is_local_winserver': json.dumps(_is_local_winserver()), 'gateway_url': gateway_url, 'server_base_url': server_base_url, 'page_title': '添加主机', @@ -765,6 +949,60 @@ def admin_host_wizard(request): ) +@admin_required +def admin_host_wizard_generate_init_command(request): + if request.method != 'POST': + return JsonResponse( + {'success': False, 'error': 'Method not allowed'}, + status=405, + ) + + import base64, json + from apps.bootstrap.models import CertProvisionToken + from apps.bootstrap.token_utils import encode_provision_token + + token_str = secrets.token_hex(32) + server_host = request.get_host() + scheme = 'https' if request.is_secure() else 'http' + + ip_address = '' + try: + body = json.loads(request.body) + ip_address = body.get('ip_address', '') + except (json.JSONDecodeError, ValueError): + ip_address = request.POST.get('ip_address', '') + + CertProvisionToken.objects.create( + token=token_str, + host=None, + server_host=server_host, + ip_address=ip_address, + expires_at=timezone.now() + timedelta(minutes=60), + status='ISSUED', + created_by=request.user, + ) + + encoded = encode_provision_token(token_str, scheme, server_host) + script_url = f"{scheme}://{server_host}/static/scripts/init.ps1" + one_liner = ( + f"$script = [Text.Encoding]::UTF8.GetString((iwr '{script_url}' -UseBasicParsing).RawContentStream.ToArray()); " + f"& ([ScriptBlock]::Create($script)) {encoded}" + ) + fallback_command = ( + f"$script = [Text.Encoding]::UTF8.GetString((iwr '{script_url}' -UseBasicParsing).RawContentStream.ToArray()); " + f"& ([ScriptBlock]::Create($script)) {encoded} debug" + ) + + return JsonResponse({ + 'success': True, + 'data': { + 'token': token_str, + 'one_liner': one_liner, + 'fallback_command': fallback_command, + }, + }) + + @admin_required def admin_host_wizard_generate_token(request): if request.method != 'POST': @@ -793,3 +1031,150 @@ def admin_host_wizard_generate_token(request): 'success': False, 'error': 'Failed to generate tunnel token', }, status=500) + + +@admin_required +def admin_host_wizard_test_connection(request): + if request.method != 'POST': + return JsonResponse({'success': False, 'error': 'Method not allowed'}, status=405) + + try: + data = json.loads(request.body) + except (json.JSONDecodeError, ValueError): + return JsonResponse({'success': False, 'error': '请求数据格式无效'}, status=400) + + connection_type = data.get('connection_type', 'winrm') + hostname = data.get('hostname', '').strip() + port = data.get('port', 5985) + use_ssl = data.get('use_ssl', False) + auth_method = data.get('auth_method', 'ntlm') + username = data.get('username', '').strip() + password = data.get('password', '') + + if not hostname: + return JsonResponse({'success': False, 'error': '主机地址不能为空'}, status=400) + + if auth_method == 'ntlm': + if not username: + return JsonResponse({'success': False, 'error': '用户名不能为空'}, status=400) + if not password: + return JsonResponse({'success': False, 'error': '密码不能为空'}, status=400) + + try: + if connection_type == 'localwinserver': + from utils.local_winserver_client import LocalWinServerClient + client = LocalWinServerClient( + username=username, + password=password, + ) + result = client.execute_command('echo Connection Test OK') + elif connection_type == 'winrm' and auth_method == 'ntlm': + from utils.winrm_client import WinrmClient + client = WinrmClient( + hostname=hostname, + port=int(port), + username=username, + password=password, + use_ssl=bool(use_ssl), + auth_method='ntlm', + ) + result = client.execute_command('whoami') + elif connection_type == 'winrm' and auth_method == 'certificate': + return JsonResponse({ + 'success': False, + 'error': '证书认证方式请先保存主机后再测试连接', + }) + else: + return JsonResponse({ + 'success': False, + 'error': f'不支持的连接类型: {connection_type}', + }) + + if result.success: + output = result.std_out.strip() if result.std_out else '' + return JsonResponse({ + 'success': True, + 'message': f'连接成功{f" ({output})" if output else ""}', + }) + else: + error_detail = result.std_err.strip() if result.std_err else f'命令执行返回非零状态码: {result.status_code}' + return JsonResponse({ + 'success': False, + 'error': f'连接失败: {error_detail}', + }) + + except Exception as e: + error_message = str(e) + logger.error(f"向导即时连接测试失败: {hostname}, 错误: {error_message}") + return JsonResponse({ + 'success': False, + 'error': f'连接测试失败: {error_message}', + }) + + +@admin_required +def admin_host_generate_init_command(request, pk): + host = get_object_or_404(Host, pk=pk) + + if request.method != 'POST': + return JsonResponse( + {'success': False, 'error': 'Method not allowed'}, + status=405, + ) + + try: + init_data = _generate_init_command_data(request, host) + return JsonResponse({'success': True, 'data': init_data}) + except Exception as e: + return JsonResponse( + {'success': False, 'error': str(e)}, + status=500, + ) + + +@login_required +def admin_host_generate_cert_command(request): + if request.method != 'POST': + return JsonResponse({'success': False, 'error': 'Method not allowed'}, status=405) + + host_id = request.POST.get('host_id') + host = get_object_or_404(Host, pk=host_id) + + if host.auth_method != 'certificate': + return JsonResponse({'success': False, 'error': 'Host is not configured for certificate auth'}, status=400) + + from apps.bootstrap.models import CertProvisionToken + from apps.bootstrap.token_utils import encode_provision_token + + token_str = secrets.token_hex(32) + server_host = request.get_host() + scheme = 'https' if request.is_secure() else 'http' + + provision_token = CertProvisionToken.objects.create( + token=token_str, + host=host, + server_host=server_host, + ip_address=host.hostname or '', + expires_at=timezone.now() + timedelta(minutes=60), + status='ISSUED', + created_by=request.user, + ) + + encoded = encode_provision_token(token_str, scheme, server_host) + script_url = f"{scheme}://{server_host}/static/scripts/init.ps1" + command = ( + f"$script = [Text.Encoding]::UTF8.GetString((iwr '{script_url}' -UseBasicParsing).RawContentStream.ToArray()); " + f"& ([ScriptBlock]::Create($script)) {encoded}" + ) + debug_command = ( + f"$script = [Text.Encoding]::UTF8.GetString((iwr '{script_url}' -UseBasicParsing).RawContentStream.ToArray()); " + f"& ([ScriptBlock]::Create($script)) {encoded} debug" + ) + + return JsonResponse({ + 'success': True, + 'command': command, + 'debug_command': debug_command, + 'token': token_str, + 'expires_at': provision_token.expires_at.isoformat(), + }) diff --git a/apps/operations/forms_wizard.py b/apps/operations/forms_wizard.py index b3e847f..569a093 100644 --- a/apps/operations/forms_wizard.py +++ b/apps/operations/forms_wizard.py @@ -88,7 +88,7 @@ class Meta: 'host', 'is_available', 'auto_approval', 'visibility', 'enable_host_protection', 'display_hostname', 'rdp_port', - 'enable_disk_quota', + 'enable_disk_quota', 'limit_one_per_user', ] widgets = { 'display_name': forms.TextInput(attrs={ @@ -143,6 +143,10 @@ class Meta: 'class': _CHECKBOX_CLASS, 'x-model': 'enableDiskQuota', }), + 'limit_one_per_user': forms.CheckboxInput(attrs={ + 'class': _CHECKBOX_CLASS, + 'x-model': 'limitOnePerUser', + }), } labels = { 'display_name': _('显示名称'), @@ -156,6 +160,7 @@ class Meta: 'display_hostname': _('显示地址'), 'rdp_port': _('RDP端口'), 'enable_disk_quota': _('启用磁盘配额管理'), + 'limit_one_per_user': _('每人限购一个'), } help_texts = { 'host': _('此产品运行所在的主机'), @@ -173,6 +178,9 @@ class Meta: '是否启用磁盘配额管理,' '启用后将自动为新用户设置磁盘配额' ), + 'limit_one_per_user': _( + '启用后,每个用户只能拥有一个此产品' + ), } def __init__(self, *args, **kwargs): diff --git a/apps/operations/migrations/0015_alter_rdpdomainroute_domain_and_more.py b/apps/operations/migrations/0015_alter_rdpdomainroute_domain_and_more.py new file mode 100644 index 0000000..6145bf1 --- /dev/null +++ b/apps/operations/migrations/0015_alter_rdpdomainroute_domain_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.30 on 2026-05-21 16:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('operations', '0014_encrypt_initial_password'), + ] + + operations = [ + migrations.AlterField( + model_name='rdpdomainroute', + name='domain', + field=models.CharField(help_text='分配给用户的临时RDP访问域名(仅用于显示/追踪,不再用于SNI路由)', max_length=255, unique=True, verbose_name='RDP域名'), + ), + migrations.AlterField( + model_name='rdpdomainroute', + name='tunnel_token', + field=models.CharField(blank=True, help_text='关联主机的隧道Token,用于RD Gateway路由', max_length=64, verbose_name='隧道Token'), + ), + ] diff --git a/apps/operations/migrations/0016_add_product_limit_one_per_user.py b/apps/operations/migrations/0016_add_product_limit_one_per_user.py new file mode 100644 index 0000000..5adde82 --- /dev/null +++ b/apps/operations/migrations/0016_add_product_limit_one_per_user.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('operations', '0015_alter_rdpdomainroute_domain_and_more'), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.AddField( + model_name='product', + name='limit_one_per_user', + field=models.BooleanField(default=False, help_text='是否限制每个用户只能拥有一个此产品', verbose_name='每人限购一个'), + ), + ], + database_operations=[], + ) + ] diff --git a/apps/operations/models.py b/apps/operations/models.py index 0fc4d4d..ce1fe45 100755 --- a/apps/operations/models.py +++ b/apps/operations/models.py @@ -7,20 +7,14 @@ from django.conf import settings from django.utils import timezone from django.dispatch import Signal +from utils.crypto import encrypt_value, decrypt_value import logging -import hashlib -import base64 User = get_user_model() logger = logging.getLogger(__name__) -def _get_fernet(): - from cryptography.fernet import Fernet - key = hashlib.sha256(settings.SECRET_KEY.encode()).digest() - return Fernet(base64.urlsafe_b64encode(key)) - # 定义开户申请提交前的信号 account_opening_request_pre_submit = Signal() # 定义开户申请提交后的信号 @@ -400,6 +394,12 @@ class Product(models.Model): help_text=_('产品的可见性:公开对所有用户可见,邀请访问仅对已授权用户可见') ) + limit_one_per_user = models.BooleanField( + default=False, + verbose_name=_('每人限购一个'), + help_text=_('是否限制每个用户只能拥有一个此产品') + ) + enable_disk_quota = models.BooleanField( default=False, verbose_name=_('启用磁盘配额管理'), @@ -732,7 +732,11 @@ def save(self, *args, **kwargs): if ((old_status == 'pending' and self.status == 'approved') or (is_new_instance and auto_approved and self.status == 'approved')): - self.auto_process_creation() + try: + from apps.operations.tasks import process_account_creation + process_account_creation.delay(self.pk) + except Exception as e: + logger.error(f"Failed to dispatch account creation task for request {self.pk}: {str(e)}") def auto_process_creation(self): """审批通过后自动创建用户""" @@ -781,15 +785,7 @@ def auto_process_creation(self): # 正式模式 try: - from utils.winrm_client import WinrmClient - - client = WinrmClient( - hostname=host.hostname, - port=host.port, - username=host.username, - password=host.password, - use_ssl=host.use_ssl - ) + client = host.get_connection_client() password = CloudComputerUser.generate_complex_password() result = client.create_user( @@ -947,14 +943,14 @@ def initial_password(self): if not self._initial_password: return '' try: - return _get_fernet().decrypt(self._initial_password.encode()).decode() - except Exception: + return decrypt_value(self._initial_password) + except ValueError: raise ValueError("密码解密失败,数据可能已损坏或密钥已变更") @initial_password.setter def initial_password(self, value): if value: - self._initial_password = _get_fernet().encrypt(value.encode()).decode() + self._initial_password = encrypt_value(value) else: self._initial_password = '' password_viewed = models.BooleanField( @@ -1026,7 +1022,7 @@ def delete_user(self): def save(self, *args, **kwargs): """ - 重写save方法,当状态改变时自动执行相应操作 + 重写save方法,当状态改变时通过Celery异步执行远程操作 """ old_status = None if self.pk: @@ -1038,96 +1034,74 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) if old_status is not None: + remote_action = None if old_status != 'disabled' and self.status == 'disabled': - self.disable_remote_user() + remote_action = 'disable' elif old_status == 'disabled' and self.status == 'active': - self.enable_remote_user() + remote_action = 'enable' elif old_status != 'deleted' and self.status == 'deleted': - self.delete_remote_user() + remote_action = 'delete' + + if remote_action: + try: + from apps.operations.tasks import execute_cloud_user_remote_action + execute_cloud_user_remote_action.delay(self.pk, remote_action) + except Exception as e: + logger.error(f"Failed to dispatch remote action '{remote_action}' for user {self.username}: {str(e)}") def disable_remote_user(self): import os if os.environ.get('2C2A_DEMO', '').lower() == '1': - import logging - logger = logging.getLogger(__name__) logger.info(f'DEMO模式: 模拟禁用用户 {self.username} 在产品 {self.product.display_name}') return try: - from utils.winrm_client import WinrmClient - product = self.product host = product.host - client = WinrmClient( - hostname=host.hostname, - port=host.port, - username=host.username, - password=host.password, - use_ssl=host.use_ssl - ) + client = host.get_connection_client() result = client.disabled_user(self.username) if result.status_code != 0: error_msg = result.std_err if result.std_err else 'Unknown error' - print(f"Failed to disable user {self.username} on host {host.name}: {error_msg}") + logger.error(f"Failed to disable user {self.username} on host {host.name}: {error_msg}") except Exception as e: - print(f"Error disabling user {self.username} on host {host.name}: {str(e)}") + logger.error(f"Error disabling user {self.username} on host {host.name}: {str(e)}") def enable_remote_user(self): import os if os.environ.get('2C2A_DEMO', '').lower() == '1': - import logging - logger = logging.getLogger(__name__) logger.info(f'DEMO模式: 模拟启用用户 {self.username} 在产品 {self.product.display_name}') return try: - from utils.winrm_client import WinrmClient - product = self.product host = product.host - client = WinrmClient( - hostname=host.hostname, - port=host.port, - username=host.username, - password=host.password, - use_ssl=host.use_ssl - ) + client = host.get_connection_client() result = client.enable_user(self.username) if result.status_code != 0: error_msg = result.std_err if result.std_err else 'Unknown error' - print(f"Failed to enable user {self.username} on host {host.name}: {error_msg}") + logger.error(f"Failed to enable user {self.username} on host {host.name}: {error_msg}") except Exception as e: - print(f"Error enabling user {self.username} on host {host.name}: {str(e)}") + logger.error(f"Error enabling user {self.username} on host {host.name}: {str(e)}") def delete_remote_user(self): import os if os.environ.get('2C2A_DEMO', '').lower() == '1': - import logging - logger = logging.getLogger(__name__) logger.info(f'DEMO模式: 模拟删除用户 {self.username} 在产品 {self.product.display_name}') return try: - from utils.winrm_client import WinrmClient - product = self.product host = product.host - client = WinrmClient( - hostname=host.hostname, - port=host.port, - username=host.username, - password=host.password, - use_ssl=host.use_ssl - ) + client = host.get_connection_client() result = client.delete_user(self.username) if result.status_code != 0: error_msg = result.std_err if result.std_err else 'Unknown error' - print(f"Failed to delete user {self.username} on host {host.name}: {error_msg}") + logger.error(f"Failed to delete user {self.username} on host {host.name}: {error_msg}") except Exception as e: - print(f"Error deleting user {self.username} on host {host.name}: {str(e)}") + logger.error(f"Error deleting user {self.username} on host {host.name}: {str(e)}") def get_and_burn_password(self): from django.utils import timezone @@ -1148,30 +1122,20 @@ def get_and_burn_password(self): def reset_windows_password(self, new_password): import os if os.environ.get('2C2A_DEMO', '').lower() == '1': - import logging - logger = logging.getLogger(__name__) logger.info(f'DEMO模式: 模拟重置用户 {self.username} 的密码') return try: - from utils.winrm_client import WinrmClient - product = self.product host = product.host - client = WinrmClient( - hostname=host.hostname, - port=host.port, - username=host.username, - password=host.password, - use_ssl=host.use_ssl - ) + client = host.get_connection_client() result = client.reset_password(self.username, new_password) if result.status_code != 0: error_msg = result.std_err if result.std_err else 'Unknown error' - print(f"Failed to reset password for user {self.username} on host {host.name}: {error_msg}") + logger.error(f"Failed to reset password for user {self.username} on host {host.name}: {error_msg}") except Exception as e: - print(f"Error resetting password for user {self.username} on host {host.name}: {str(e)}") + logger.error(f"Error resetting password for user {self.username} on host {host.name}: {str(e)}") @staticmethod def generate_complex_password(length=16): @@ -1388,8 +1352,10 @@ def is_valid(self): return self.is_active and not self.is_expired() and not self.is_exhausted() def increment_usage(self): - self.used_count += 1 + from django.db.models import F + self.used_count = F('used_count') + 1 self.save(update_fields=['used_count', 'updated_at']) + self.refresh_from_db() def generate_token(self): import secrets diff --git a/apps/operations/tasks.py b/apps/operations/tasks.py index 5e0c54c..bcdfefb 100755 --- a/apps/operations/tasks.py +++ b/apps/operations/tasks.py @@ -64,8 +64,6 @@ def process_opening_request(self, request_id, operator_id): message="找到可用主机" ) - from utils.winrm_client import WinrmClient - username = request_obj.username password = generate_secure_password() @@ -78,13 +76,7 @@ def process_opening_request(self, request_id, operator_id): message="执行PowerShell命令创建用户" ) - client = WinrmClient( - hostname=available_host.hostname, - port=available_host.port, - username=available_host.username, - password=available_host.password, - use_ssl=available_host.use_ssl - ) + client = available_host.get_connection_client() result = client.create_user( username=username, @@ -170,6 +162,68 @@ def process_opening_request(self, request_id, operator_id): } +@shared_task( + bind=True, + max_retries=3, + default_retry_delay=30, + autoretry_for=(Exception,), +) +def execute_cloud_user_remote_action(self, user_id, action): + """ + 异步执行云电脑用户的远程操作(禁用/启用/删除) + 将远程 WinRM 调用从 save() 中解耦,避免阻塞数据库事务 + """ + try: + cloud_user = CloudComputerUser.objects.get(pk=user_id) + except CloudComputerUser.DoesNotExist: + logger.error(f"CloudComputerUser with pk={user_id} does not exist, skipping remote action '{action}'") + return {'success': False, 'error': f'User {user_id} not found'} + + if action == 'disable': + cloud_user.disable_remote_user() + elif action == 'enable': + cloud_user.enable_remote_user() + elif action == 'delete': + cloud_user.delete_remote_user() + else: + logger.error(f"Unknown remote action '{action}' for user {cloud_user.username}") + return {'success': False, 'error': f'Unknown action: {action}'} + + return {'success': True, 'action': action, 'user_id': user_id} + + +@shared_task( + bind=True, + max_retries=2, + default_retry_delay=60, +) +def process_account_creation(self, request_id): + """ + 异步处理开户请求的用户创建流程 + 将远程 WinRM 调用从 AccountOpeningRequest.save() 中解耦 + """ + try: + request_obj = AccountOpeningRequest.objects.get(pk=request_id) + except AccountOpeningRequest.DoesNotExist: + logger.error(f"AccountOpeningRequest with pk={request_id} does not exist") + return {'success': False, 'error': f'Request {request_id} not found'} + + try: + request_obj.auto_process_creation() + return {'success': True, 'request_id': request_id} + except Exception as e: + logger.error(f"Account creation failed for request {request_id}: {str(e)}", exc_info=True) + try: + request_obj.refresh_from_db() + if request_obj.status not in ('completed', 'failed'): + request_obj.status = 'failed' + request_obj.result_message = f"异步处理异常: {str(e)}" + request_obj.save(update_fields=['status', 'result_message']) + except Exception as save_err: + logger.error(f"Failed to update request status: {str(save_err)}") + return {'success': False, 'error': str(e)} + + @shared_task def cleanup_expired_rdp_domains(): from django.utils import timezone @@ -245,14 +299,7 @@ def rollback_opening_request(request_id): try: request_obj = AccountOpeningRequest.objects.get(id=request_id) if request_obj.host and request_obj.windows_username: - from utils.winrm_client import WinrmClient - client = WinrmClient( - hostname=request_obj.host.hostname, - port=request_obj.host.port, - username=request_obj.host.username, - password=request_obj.host.password, - use_ssl=request_obj.host.use_ssl - ) + client = request_obj.host.get_connection_client() result = client.disabled_user(request_obj.windows_username) @@ -285,14 +332,7 @@ def reset_user_password(self, user_id, operator_id): new_password = generate_secure_password() - from utils.winrm_client import WinrmClient - client = WinrmClient( - hostname=user.host.hostname, - port=user.host.port, - username=user.host.username, - password=user.host.password, - use_ssl=user.host.use_ssl - ) + client = user.host.get_connection_client() result = client.reset_password(user.windows_username, new_password) @@ -411,14 +451,7 @@ def cleanup_inactive_users(self, days_inactive=30): cleaned_count = 0 for user in inactive_users: - from utils.winrm_client import WinrmClient - client = WinrmClient( - hostname=user.host.hostname, - port=user.host.port, - username=user.host.username, - password=user.host.password, - use_ssl=user.host.use_ssl - ) + client = user.host.get_connection_client() result = client.disabled_user(user.windows_username) diff --git a/apps/operations/views.py b/apps/operations/views.py index 36ae905..11e5c60 100755 --- a/apps/operations/views.py +++ b/apps/operations/views.py @@ -524,6 +524,8 @@ def product_invite_view(request, token): 产品邀请链接视图 用户访问邀请链接后,解锁对应产品或产品组的访问权限 + GET: 显示邀请信息和确认页面 + POST: 确认接受邀请,执行授权操作 """ from django.shortcuts import render, redirect from django.contrib import messages @@ -542,7 +544,6 @@ def product_invite_view(request, token): 'message': '邀请链接无效或不存在。', }) - # 校验令牌状态 if not invite_token.is_valid(): if invite_token.is_expired(): return render(request, 'operations/invite_result.html', { @@ -559,12 +560,10 @@ def product_invite_view(request, token): 'message': '邀请链接已被禁用。', }) - # 如果用户未登录,重定向到登录页 if not request.user.is_authenticated: login_url = reverse('accounts:login') + f'?next={request.path}' return redirect(login_url) - # 检查是否已有授权 existing_grant = ProductAccessGrant.objects.filter( user=request.user, product=invite_token.product, @@ -580,7 +579,21 @@ def product_invite_view(request, token): 'product_group': invite_token.product_group, }) - # 创建授权记录 + if request.method == 'GET': + target_name = ( + invite_token.product.display_name + if invite_token.product + else (invite_token.product_group.name if invite_token.product_group else '未知') + ) + return render(request, 'operations/invite_result.html', { + 'success': None, + 'message': f'您即将解锁 "{target_name}" 的访问权限。', + 'product': invite_token.product, + 'product_group': invite_token.product_group, + 'needs_confirm': True, + 'token': token, + }) + try: grant, created = ProductAccessGrant.objects.get_or_create( user=request.user, @@ -592,7 +605,6 @@ def product_invite_view(request, token): } ) if not created: - # 如果记录已存在但被撤销或过期,重新激活 if grant.is_revoked or grant.is_expired(): grant.is_revoked = False grant.revoked_at = None @@ -610,7 +622,6 @@ def product_invite_view(request, token): 'message': '授权处理失败,请稍后重试。', }) - # 更新令牌使用次数 invite_token.increment_usage() target_name = ( diff --git a/apps/operations/views_admin.py b/apps/operations/views_admin.py index 4373529..f618b9d 100644 --- a/apps/operations/views_admin.py +++ b/apps/operations/views_admin.py @@ -1095,13 +1095,8 @@ def admin_cloud_user_action(request, pk): cloud_user.is_admin = True cloud_user.save(update_fields=['is_admin', 'updated_at']) try: - from utils.winrm_client import WinrmClient host = cloud_user.product.host - client = WinrmClient( - hostname=host.hostname, port=host.port, - username=host.username, password=host.password, - use_ssl=host.use_ssl, - ) + client = host.get_connection_client() client.op_user(cloud_user.username) except Exception as e: logger.error(f'远程设置管理员失败: {e}') @@ -1117,13 +1112,8 @@ def admin_cloud_user_action(request, pk): cloud_user.is_admin = False cloud_user.save(update_fields=['is_admin', 'updated_at']) try: - from utils.winrm_client import WinrmClient host = cloud_user.product.host - client = WinrmClient( - hostname=host.hostname, port=host.port, - username=host.username, password=host.password, - use_ssl=host.use_ssl, - ) + client = host.get_connection_client() client.deop_user(cloud_user.username) except Exception as e: logger.error(f'远程取消管理员失败: {e}') @@ -1193,13 +1183,8 @@ def admin_cloud_user_set_quota(request, pk): try: from utils.disk_quota import set_disk_quota_via_client - from utils.winrm_client import WinrmClient host = cloud_user.product.host - client = WinrmClient( - hostname=host.hostname, port=host.port, - username=host.username, password=host.password, - use_ssl=host.use_ssl, - ) + client = host.get_connection_client() result = set_disk_quota_via_client(client, cloud_user.username, disk, quota_mb) if not result['success']: return JsonResponse({'success': False, 'message': f'远程设置配额失败: {result["message"]}'}) diff --git a/celerybeat-schedule b/celerybeat-schedule new file mode 100644 index 0000000..4abb835 Binary files /dev/null and b/celerybeat-schedule differ diff --git a/config/__init__.py b/config/__init__.py index 70ed68d..9d284a2 100755 --- a/config/__init__.py +++ b/config/__init__.py @@ -1,3 +1,6 @@ """ 2c2a配置模块 """ +from .celery import app as celery_app # noqa: E402,F401 + +__all__ = ('celery_app',) diff --git a/config/celery.py b/config/celery.py index 2956c7e..c62047f 100755 --- a/config/celery.py +++ b/config/celery.py @@ -1,26 +1,50 @@ import os from celery import Celery -from django.conf import settings +from celery.schedules import crontab -# 设置Django环境 os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') app = Celery('2c2a') app.config_from_object('django.conf:settings', namespace='CELERY') -# 自动发现任务 +from django.conf import settings # noqa: E402 + +_redis_url = getattr(settings, 'REDIS_URL', '') +_base_dir = str(settings.BASE_DIR) + +if not app.conf.broker_url: + _broker = getattr(settings, 'CELERY_BROKER_URL', None) + if _broker: + app.conf.broker_url = _broker + elif _redis_url: + app.conf.broker_url = _redis_url.replace('/0', '/1') + else: + app.conf.broker_url = ( + f'sqla+sqlite:///{_base_dir}/celery_broker.sqlite3' + ) + +if not app.conf.result_backend: + _result = getattr(settings, 'CELERY_RESULT_BACKEND', None) + if _result: + app.conf.result_backend = _result + elif _redis_url: + app.conf.result_backend = _redis_url.replace('/0', '/2') + else: + app.conf.result_backend = ( + f'db+sqlite:///{_base_dir}/celery_results.sqlite3' + ) + app.autodiscover_tasks() -# 任务序列化配置 app.conf.update( task_serializer='json', accept_content=['json'], result_serializer='json', timezone='UTC', enable_utc=True, + broker_connection_retry_on_startup=True, ) -# 队列配置 app.conf.task_routes = { 'certificates.tasks.*': {'queue': 'certificates'}, 'hosts.tasks.*': {'queue': 'hosts'}, @@ -29,6 +53,20 @@ 'plugins.beta_push.tasks.*': {'queue': 'beta_push'}, } -# 任务重试配置 -app.conf.task_default_retry_delay = 30 # 默认重试延迟30秒 -app.conf.task_max_retries = 3 # 最大重试次数3次 \ No newline at end of file +app.conf.task_default_retry_delay = 30 +app.conf.task_max_retries = 3 + +app.conf.CELERY_BEAT_SCHEDULE = { + 'cleanup-expired-provision-tokens': { + 'task': 'apps.bootstrap.tasks.cleanup_expired_provision_tokens', + 'schedule': crontab(hour='0', minute='0'), + }, + 'cleanup-unactivated-certificates': { + 'task': 'apps.bootstrap.tasks.cleanup_unactivated_certificates', + 'schedule': crontab(hour='0', minute='0'), + }, + 'cleanup-orphan-cert-dirs': { + 'task': 'apps.bootstrap.tasks.cleanup_orphan_cert_dirs', + 'schedule': crontab(hour='0', minute='0'), + }, +} diff --git a/config/security_middleware.py b/config/security_middleware.py index 250e71a..05c7e33 100644 --- a/config/security_middleware.py +++ b/config/security_middleware.py @@ -10,32 +10,15 @@ def __call__(self, request): if not settings.DEBUG: csp_parts = [ "default-src 'self'", - - # JS:极验 + 你的静态站 "script-src 'self' 'unsafe-inline' 'unsafe-eval' " - "https://static.2c2a.cc.cd " - "https://static.geetest.com https://static.geevisit.com " - "https://gcaptcha4.geetest.com https://gcaptcha4.geevisit.com", - - # CSS:极验 + 你的静态站(关键:之前这里没加极验域名,所以 CSS 被拦) + "https://static.2c2a.cc.cd", "style-src 'self' 'unsafe-inline' " - "https://static.2c2a.cc.cd " - "https://static.geetest.com https://static.geevisit.com", - - # 图片:极验 + 你的静态站 + "https://static.2c2a.cc.cd", "img-src 'self' data: blob: " - "https://static.2c2a.cc.cd " - "https://static.geetest.com https://static.geevisit.com", - - # 字体:极验 + 你的静态站(极验也用到了字体) + "https://static.2c2a.cc.cd", "font-src 'self' " - "https://static.2c2a.cc.cd " - "https://static.geetest.com https://static.geevisit.com", - - # AJAX / WebSocket:极验接口 - "connect-src 'self' wss: ws: " - "https://gcaptcha4.geetest.com https://gcaptcha4.geevisit.com", - + "https://static.2c2a.cc.cd", + "connect-src 'self' wss://rdp.2c2a.com ws://rdp.2c2a.com", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'", diff --git a/config/settings.py b/config/settings.py index a8f6b5a..ab62984 100644 --- a/config/settings.py +++ b/config/settings.py @@ -132,6 +132,8 @@ def _env(key, default=None): 'django_cotton', # 本地应用 + 'django_tianai_captcha', + 'apps.accounts', 'apps.hosts', 'apps.operations', @@ -190,12 +192,12 @@ def _discover_plugin_apps(): MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', - 'config.maintenance_middleware.MaintenanceModeMiddleware', - 'config.local_lock_middleware.LocalLockMiddleware', - 'config.security_middleware.SecurityHeadersMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'corsheaders.middleware.CorsMiddleware', 'django.middleware.common.CommonMiddleware', + 'config.maintenance_middleware.MaintenanceModeMiddleware', + 'config.local_lock_middleware.LocalLockMiddleware', + 'config.security_middleware.SecurityHeadersMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', @@ -553,3 +555,27 @@ def _check_redis_available(): _bootstrap_logging.getLogger('2c2a').warning( 'BOOTSTRAP_SHARED_SALT 未设置,建议在生产环境中配置此值以增强引导认证安全性' ) + +CAPTCHA = { + "PREFIX": "captcha", + "EXPIRE": { + "default": 120, + "WORD_IMAGE_CLICK": 180, + }, + "INIT_DEFAULT_RESOURCE": True, + "CACHE_BACKEND": "redis" if REDIS_ENABLED else "local", + "REDIS_URL": REDIS_URL if REDIS_ENABLED else "", + "DEFAULT_TYPE": "SLIDER", + "TOLERANT": 0.02, + "TRACK_VALIDATION_ENABLED": True, + "SECONDARY": { + "ENABLED": True, + "EXPIRE": 120, + "KEY_PREFIX": "captcha:secondary", + }, + "RATE_LIMIT": { + "ENABLED": True, + "RATE": 10, + "PERIOD": 60, + }, +} diff --git a/config/urls.py b/config/urls.py index b7cf15e..1616acc 100755 --- a/config/urls.py +++ b/config/urls.py @@ -19,6 +19,7 @@ path('audit/', include('apps.audit.urls')), path('tunnel/', include('apps.tunnel.urls')), path('tickets/', include('apps.tickets.urls')), + path('captcha/', include('django_tianai_captcha.urls')), path('docs/', views.docs_index, name='docs_index'), path('', include('apps.dashboard.urls')), path('404/', TemplateView.as_view(template_name='errors/404.html'), name='404'), diff --git a/plugins/beta_push/apps.py b/plugins/beta_push/apps.py index 31979f4..6c3389c 100644 --- a/plugins/beta_push/apps.py +++ b/plugins/beta_push/apps.py @@ -26,33 +26,22 @@ def _configure_beta_database(self): default_db = settings.DATABASES.get('default', {}) engine = default_db.get('ENGINE', '') - if engine not in ( - 'django.db.backends.mysql', - 'django.db.backends.postgresql', - ): + if engine != 'django.db.backends.postgresql': logger.warning( - 'Beta推送插件仅支持MySQL/PostgreSQL架构,' - '当前默认数据库引擎不是受支持的引擎' + 'Beta推送插件仅支持PostgreSQL架构,' + '当前默认数据库引擎不是PostgreSQL' ) return beta_db = { + 'ENGINE': 'django.db.backends.postgresql', 'NAME': beta_db_name, 'USER': os.environ.get('BETA_DB_USER', default_db.get('USER', '')), 'PASSWORD': os.environ.get('BETA_DB_PASSWORD', default_db.get('PASSWORD', '')), 'HOST': os.environ.get('BETA_DB_HOST', default_db.get('HOST', '127.0.0.1')), - 'PORT': os.environ.get('BETA_DB_PORT', default_db.get('PORT', '3306')), + 'PORT': os.environ.get('BETA_DB_PORT', default_db.get('PORT', '5432')), 'CONN_MAX_AGE': int(os.environ.get('BETA_DB_CONN_MAX_AGE', '60')), } - if engine == 'django.db.backends.mysql': - beta_db['ENGINE'] = 'django.db.backends.mysql' - beta_db['OPTIONS'] = { - 'charset': 'utf8mb4', - 'init_command': "SET sql_mode='STRICT_TRANS_TABLES'", - } - elif engine == 'django.db.backends.postgresql': - beta_db['ENGINE'] = 'django.db.backends.postgresql' - settings.DATABASES['beta'] = beta_db logger.info(f'Beta数据库已配置: {beta_db_name}') diff --git a/plugins/beta_push/services.py b/plugins/beta_push/services.py index f0625f9..8af65a8 100644 --- a/plugins/beta_push/services.py +++ b/plugins/beta_push/services.py @@ -1,9 +1,10 @@ import logging +import os from collections import OrderedDict from django.conf import settings from django.contrib.auth import get_user_model -from django.db import models +from django.db import connections, models, IntegrityError from django.utils import timezone import redis as redis_lib @@ -15,6 +16,11 @@ REDIS_KEY_PREFIX = 'beta_push:progress' +ENCRYPTED_FIELDS = { + 'hosts.Host._password', + 'operations.CloudComputerUser._initial_password', +} + def _get_redis(): url = getattr(settings, 'REDIS_URL', '') @@ -55,7 +61,41 @@ def get_progress(task_id): return None +def _get_fernet(secret_key): + try: + from cryptography.fernet import Fernet + import base64 + import hashlib + key = hashlib.sha256(secret_key.encode()).digest() + return Fernet(base64.urlsafe_b64encode(key)) + except ImportError: + return None + + +def _re_encrypt_value(encrypted_value, model_label, field_name): + beta_secret_key = os.environ.get('BETA_SECRET_KEY', '') + if not beta_secret_key or not encrypted_value: + return encrypted_value + + field_key = f'{model_label}.{field_name}' + if field_key not in ENCRYPTED_FIELDS: + return encrypted_value + + prod_fernet = _get_fernet(settings.SECRET_KEY) + beta_fernet = _get_fernet(beta_secret_key) + if not prod_fernet or not beta_fernet: + return encrypted_value + + try: + plaintext = prod_fernet.decrypt(encrypted_value.encode()).decode() + return beta_fernet.encrypt(plaintext.encode()).decode() + except Exception as e: + logger.warning(f'重加密失败 [{field_key}]: {e}') + return encrypted_value + + class BetaPushService: + _beta_schema_cache = {} def __init__(self, user_id, task_id=''): self.user_id = user_id @@ -68,6 +108,7 @@ def __init__(self, user_id, task_id=''): 'errors': [], } self._synced_pks = {} + self._missing_tables = set() self.last_sync_at = self._get_last_sync_at() def _get_last_sync_at(self): @@ -82,6 +123,8 @@ def _get_last_sync_at(self): return None def push_all(self): + self.__class__._beta_schema_cache.clear() + steps = [ ('用户信息', self._push_user), ('用户资料', self._push_user_profile), @@ -122,6 +165,35 @@ def _is_changed(self, instance): return True return False + def _get_beta_table_info(self, model): + table_name = model._meta.db_table + if table_name in self._beta_schema_cache: + return self._beta_schema_cache[table_name] + + info = {'exists': False, 'columns': set(), 'not_null_no_default': set()} + + try: + with connections[BETA_DB].cursor() as cursor: + cursor.execute( + "SELECT column_name, is_nullable, column_default " + "FROM information_schema.columns " + "WHERE table_schema = 'public' AND table_name = %s", + [table_name] + ) + rows = cursor.fetchall() + + if rows: + info['exists'] = True + for col_name, is_nullable, col_default in rows: + info['columns'].add(col_name) + if is_nullable == 'NO' and col_default is None: + info['not_null_no_default'].add(col_name) + except Exception as e: + logger.error(f'查询Beta数据库表结构失败 [{table_name}]: {e}') + + self._beta_schema_cache[table_name] = info + return info + def _sync_instance(self, instance): model = instance.__class__ model_label = f'{model._meta.app_label}.{model.__name__}' @@ -131,12 +203,24 @@ def _sync_instance(self, instance): self.stats['skipped'] += 1 return True + table_info = self._get_beta_table_info(model) + if not table_info['exists']: + if model._meta.db_table not in self._missing_tables: + self.stats['errors'].append( + f'{model.__name__}: Beta数据库中表 {model._meta.db_table} 不存在,已跳过' + ) + self._missing_tables.add(model._meta.db_table) + self._synced_pks.setdefault(model_label, set()).add(pk) + self.stats['skipped'] += 1 + return True + if not self._is_changed(instance): if model.objects.using(BETA_DB).filter(pk=pk).exists(): self._synced_pks.setdefault(model_label, set()).add(pk) self.stats['skipped'] += 1 return True + beta_columns = table_info['columns'] field_values = {} m2m_values = OrderedDict() @@ -155,6 +239,9 @@ def _sync_instance(self, instance): if not hasattr(instance, field.attname): continue + if not hasattr(field, 'column') or field.column not in beta_columns: + continue + value = getattr(instance, field.attname) if isinstance(field, models.ForeignKey): @@ -166,6 +253,8 @@ def _sync_instance(self, instance): except Exception: pass + value = _re_encrypt_value(value, model_label, field.name) + field_values[field.name] = value try: @@ -173,34 +262,68 @@ def _sync_instance(self, instance): pk=pk, defaults=field_values, ) - - for field_name, related_pks in m2m_values.items(): + except IntegrityError as e: + logger.warning(f'IntegrityError [{model.__name__}:{pk}]: {e}') + if model.objects.using(BETA_DB).filter(pk=pk).exists(): try: - m2m_model = model._meta.get_field(field_name).related_model - existing_pks = set( - m2m_model.objects.using(BETA_DB).filter( - pk__in=related_pks - ).values_list('pk', flat=True) + model.objects.using(BETA_DB).filter(pk=pk).update(**field_values) + obj = model.objects.using(BETA_DB).get(pk=pk) + except Exception as e2: + self.stats['failed'] += 1 + self.stats['errors'].append( + f'{model.__name__}:{pk} - 更新失败: {str(e2)}' ) - m2m_manager = getattr(obj, field_name) - m2m_manager.set(existing_pks) - except Exception as e: - logger.warning(f'M2M同步失败 [{model.__name__}.{field_name}]: {e}') + return False + else: + prod_cols = { + f.column for f in model._meta.concrete_fields if hasattr(f, 'column') + } + missing = table_info['not_null_no_default'] - prod_cols + hint = ( + f'Beta数据库存在额外NOT NULL无默认值列: {missing}' + if missing else str(e) + ) + self.stats['failed'] += 1 + self.stats['errors'].append( + f'{model.__name__}:{pk} - 创建失败: {hint}' + ) + return False - self._synced_pks.setdefault(model_label, set()).add(pk) - self.stats['pushed'] += 1 - return True - except Exception as e: - logger.error(f'同步实例失败 [{model.__name__}:{pk}]: {e}', exc_info=True) - self.stats['failed'] += 1 - self.stats['errors'].append(f'{model.__name__}:{pk} - {str(e)}') - return False + for field_name, related_pks in m2m_values.items(): + try: + m2m_field = model._meta.get_field(field_name) + m2m_through_info = self._get_beta_table_info( + m2m_field.remote_field.through + ) + if not m2m_through_info['exists']: + logger.warning( + f'M2M中间表不存在于Beta数据库 [{model.__name__}.{field_name}]' + ) + continue + + m2m_model = m2m_field.related_model + existing_pks = set( + m2m_model.objects.using(BETA_DB).filter( + pk__in=related_pks + ).values_list('pk', flat=True) + ) + m2m_manager = getattr(obj, field_name) + m2m_manager.set(existing_pks) + except Exception as e: + logger.warning(f'M2M同步失败 [{model.__name__}.{field_name}]: {e}') + + self._synced_pks.setdefault(model_label, set()).add(pk) + self.stats['pushed'] += 1 + return True def _ensure_stub_exists(self, related_instance): model = related_instance.__class__ model_label = f'{model._meta.app_label}.{model.__name__}' pk = related_instance.pk + if model._meta.db_table in self._missing_tables: + return + if model_label in self._synced_pks and pk in self._synced_pks[model_label]: return diff --git a/plugins/beta_push/templates/beta_push/dashboard.html b/plugins/beta_push/templates/beta_push/dashboard.html index e260c3a..c4853a3 100644 --- a/plugins/beta_push/templates/beta_push/dashboard.html +++ b/plugins/beta_push/templates/beta_push/dashboard.html @@ -26,10 +26,10 @@

Beta数据库未配置

请在环境变量中配置以下参数后重启服务:

BETA_DB_NAME=zasca_beta

-

BETA_DB_USER=root

+

BETA_DB_USER=postgres

BETA_DB_PASSWORD=your_password

BETA_DB_HOST=127.0.0.1

-

BETA_DB_PORT=3306

+

BETA_DB_PORT=5432

diff --git a/plugins/management/commands/plugin.py b/plugins/management/commands/plugin.py index cfe49f8..a4768ec 100755 --- a/plugins/management/commands/plugin.py +++ b/plugins/management/commands/plugin.py @@ -1,6 +1,7 @@ """ 插件管理命令 提供类似 pip 的插件管理功能,支持安装、卸载、搜索、登录等操作 +支持从文件安装:将插件目录解压到 plugins/ 目录后,使用 scan 发现并 install 安装 """ import os import sys @@ -9,6 +10,8 @@ import re import toml import inspect +import zipfile +import tempfile from django.core.management.base import BaseCommand, CommandError from django.conf import settings from plugins.core.plugin_manager import get_plugin_manager @@ -29,8 +32,8 @@ class Command(BaseCommand): help = '插件管理命令,类似 pip 的功能' def add_arguments(self, parser): - parser.add_argument('action', type=str, help='操作类型: install, upgrade, uninstall, list, info, search, login, enable, disable') - parser.add_argument('plugin_name', nargs='?', type=str, help='插件名称或本地路径') + parser.add_argument('action', type=str, help='操作类型: install, upgrade, uninstall, list, info, search, scan, login, enable, disable') + parser.add_argument('plugin_name', nargs='?', type=str, help='插件名称、本地路径或zip文件路径') parser.add_argument('--source', type=str, help='插件源地址或本地路径') parser.add_argument('--force', action='store_true', help='强制执行操作') parser.add_argument('--no-migrate', action='store_true', help='跳过数据库迁移') @@ -38,6 +41,7 @@ def add_arguments(self, parser): parser.add_argument('--registry', type=str, default=PLUGIN_REGISTRY_URL, help='插件仓库地址') parser.add_argument('--force-github', action='store_true', help='强制使用 GitHub 插件仓库') parser.add_argument('--force-gitee', action='store_true', help='强制使用 Gitee 插件仓库镜像') + parser.add_argument('--install-all', action='store_true', help='与 scan 配合使用,安装所有发现的未注册插件') def handle(self, *args, **options): action = options['action'] @@ -53,9 +57,14 @@ def handle(self, *args, **options): if action == 'list': self.list_plugins() + elif action == 'scan': + self.scan_plugins( + install_all=options.get('install_all', False), + no_migrate=no_migrate, + ) elif action == 'install': if not plugin_name: - raise CommandError('安装插件需要指定插件名称或路径') + raise CommandError('安装插件需要指定插件名称、路径或zip文件') self.install_plugin( plugin_name, options.get('source'), @@ -96,7 +105,7 @@ def handle(self, *args, **options): raise CommandError( f'未知的操作: {action}. ' f'支持的操作: install, upgrade, uninstall, ' - f'list, info, search, login, enable, disable' + f'list, info, search, scan, login, enable, disable' ) def _fetch_registry(self, registry_url=None): @@ -175,6 +184,201 @@ def search_plugins(self, keyword='', registry_url=None): self.stdout.write(f' 版本: {info.get("version", "N/A")}') self.stdout.write('') + def scan_plugins(self, install_all=False, no_migrate=False): + plugins_base = os.path.join(settings.BASE_DIR, 'plugins') + if not os.path.isdir(plugins_base): + raise CommandError(f'插件目录不存在: {plugins_base}') + + registered_ids = set(ALL_AVAILABLE_PLUGINS.keys()) + db_ids = set( + PluginRecord.objects.values_list('plugin_id', flat=True) + ) + loaded_plugins = get_plugin_manager().get_all_plugins() + loaded_ids = set(loaded_plugins.keys()) + + skip_dirs = {'core', 'management', 'templatetags', 'migrations', + '__pycache__', '.git'} + + discovered = [] + + for entry in sorted(os.listdir(plugins_base)): + entry_path = os.path.join(plugins_base, entry) + if not os.path.isdir(entry_path): + continue + if entry in skip_dirs: + continue + if entry.startswith('.') or entry.startswith('_'): + continue + init_file = os.path.join(entry_path, '__init__.py') + if not os.path.exists(init_file): + continue + + has_plugin_class = self._detect_plugin_class(entry, entry_path) + if not has_plugin_class: + continue + + is_registered = entry in registered_ids + is_in_db = entry in db_ids + is_loaded = entry in loaded_ids + + if is_registered or is_in_db or is_loaded: + continue + + plugin_class, plugin_module_name = self._load_plugin_class_from_package( + entry, entry_path + ) + plugin_name = entry + plugin_version = '0.0.0' + plugin_desc = '' + if plugin_class: + try: + inst = plugin_class() + plugin_name = inst.name + plugin_version = inst.version + plugin_desc = inst.description + except Exception as exc: + self.stderr.write( + f"Warning: failed to read metadata from plugin '{entry}': {exc}" + ) + + discovered.append({ + 'dir_name': entry, + 'path': entry_path, + 'name': plugin_name, + 'version': plugin_version, + 'description': plugin_desc, + 'plugin_class': plugin_class, + 'plugin_module_name': plugin_module_name, + }) + + if not discovered: + self.stdout.write(self.style.SUCCESS('没有发现未注册的插件')) + return + + self.stdout.write(self.style.SUCCESS( + f'发现 {len(discovered)} 个未注册的插件:' + )) + self.stdout.write('') + for info in discovered: + self.stdout.write(f' {self.style.SUCCESS(info["dir_name"])}') + self.stdout.write(f' 名称: {info["name"]}') + self.stdout.write(f' 版本: {info["version"]}') + self.stdout.write(f' 描述: {info["description"]}') + self.stdout.write(f' 路径: {info["path"]}') + self.stdout.write('') + + if install_all: + self.stdout.write('正在安装所有发现的插件...') + for info in discovered: + try: + app_label = self._register_discovered_plugin( + info, no_migrate=no_migrate + ) + self.stdout.write(self.style.SUCCESS( + f' ✓ {info["name"]} 安装完成' + )) + except Exception as e: + self.stdout.write(self.style.ERROR( + f' ✗ {info["dir_name"]} 安装失败: {str(e)}' + )) + else: + self.stdout.write( + '使用 "uv run python manage.py plugin install <目录名>" ' + '安装单个插件\n' + '使用 "uv run python manage.py plugin scan --install-all" ' + '安装所有发现的插件' + ) + + def _detect_plugin_class(self, dir_name, dir_path): + init_file = os.path.join(dir_path, '__init__.py') + if os.path.exists(init_file): + try: + with open(init_file, 'r', encoding='utf-8') as f: + content = f.read() + if re.search( + r'class\s+\w+\s*\([^)]*PluginInterface', + content, re.DOTALL + ): + return True + if 'PLUGIN_INFO' in content: + return True + except Exception as e: + self.stdout.write( + self.style.WARNING( + f'读取插件 {dir_name} 的 __init__.py 失败: {e}' + ) + ) + + for item in os.listdir(dir_path): + if not item.endswith('.py') or item == '__init__.py': + continue + fp = os.path.join(dir_path, item) + try: + with open(fp, 'r', encoding='utf-8') as f: + content = f.read() + if re.search( + r'class\s+\w+\s*\([^)]*PluginInterface', + content, re.DOTALL + ): + return True + except Exception: + continue + + return False + + def _register_discovered_plugin(self, info, no_migrate=False): + plugin_class = info['plugin_class'] + plugin_module_name = info['plugin_module_name'] + dir_name = info['dir_name'] + + if not plugin_class: + plugin_class, plugin_module_name = self._load_plugin_class_from_package( + dir_name, info['path'] + ) + + if not plugin_class: + raise CommandError( + f'在 {info["path"]} 中未找到有效的插件类' + ) + + plugin_instance = plugin_class() + plugin_manager = get_plugin_manager() + plugin_manager.plugins[plugin_instance.plugin_id] = plugin_instance + + try: + plugin_instance.initialize() + except Exception as e: + self.stdout.write( + self.style.WARNING( + f'插件 {plugin_instance.plugin_id} 初始化失败,已继续安装流程: {e}' + ) + ) + + PluginRecord.objects.update_or_create( + plugin_id=plugin_instance.plugin_id, + defaults={ + 'name': plugin_instance.name, + 'version': plugin_instance.version, + 'description': plugin_instance.description, + 'is_active': True, + } + ) + + self.add_plugin_to_toml_config(plugin_instance.plugin_id, { + 'name': plugin_instance.name, + 'module': plugin_module_name, + 'class': plugin_class.__name__, + 'description': plugin_instance.description, + 'version': plugin_instance.version, + 'enabled': True + }) + + app_label = self._get_app_label_from_module(plugin_module_name) + if app_label and not no_migrate: + self._run_migrate(app_label) + + return app_label + def login_github(self): self.stdout.write('正在检查 GitHub CLI 认证状态...') try: @@ -213,7 +417,11 @@ def install_plugin(self, plugin_name, source=None, force=False, registry_url=Non app_label = None - if os.path.exists(plugin_name) and os.path.isdir(plugin_name): + if plugin_name.endswith('.zip'): + if not os.path.exists(plugin_name): + raise CommandError(f'zip 文件不存在: {plugin_name}') + app_label = self.install_from_zip(plugin_name, force=force, no_migrate=no_migrate) + elif os.path.exists(plugin_name) and os.path.isdir(plugin_name): app_label = self.install_from_path(plugin_name) else: plugin_path = os.path.join( @@ -236,7 +444,9 @@ def install_plugin(self, plugin_name, source=None, force=False, registry_url=Non found = True break if not found: - if source and os.path.exists(source) and os.path.isdir(source): + if source and source.endswith('.zip') and os.path.exists(source): + app_label = self.install_from_zip(source, force=force, no_migrate=no_migrate) + elif source and os.path.exists(source) and os.path.isdir(source): app_label = self.install_from_path(source) else: app_label = self.install_from_registry( @@ -246,6 +456,111 @@ def install_plugin(self, plugin_name, source=None, force=False, registry_url=Non if app_label and not no_migrate: self._run_migrate(app_label) + def install_from_zip(self, zip_path, force=False, no_migrate=False): + if not zipfile.is_zipfile(zip_path): + raise CommandError(f'不是有效的 zip 文件: {zip_path}') + + plugins_base = os.path.join(settings.BASE_DIR, 'plugins') + zip_basename = os.path.basename(zip_path) + plugin_dir_name = os.path.splitext(zip_basename)[0] + + with zipfile.ZipFile(zip_path, 'r') as zf: + top_dirs = set() + for name in zf.namelist(): + parts = name.split('/') + if len(parts) > 1 and parts[0]: + top_dirs.add(parts[0]) + + if len(top_dirs) == 1: + single_dir = top_dirs.pop() + if single_dir == plugin_dir_name or single_dir.replace('-', '_') == plugin_dir_name: + plugin_dir_name = single_dir + + if len(top_dirs) == 1: + single_dir = list(top_dirs)[0] if not plugin_dir_name else plugin_dir_name + has_init = any( + n == f'{single_dir}/__init__.py' or n.startswith(f'{single_dir}/') + for n in zf.namelist() + if n.endswith('__init__.py') + ) + if not has_init: + nested = [n for n in zf.namelist() + if n.startswith(f'{single_dir}/') and n.endswith('/')] + if nested: + for sub in nested: + sub_name = sub.rstrip('/').split('/')[-1] + sub_init = f'{single_dir}/{sub_name}/__init__.py' + if sub_init in zf.namelist(): + plugin_dir_name = sub_name + break + + target_dir = os.path.join(plugins_base, plugin_dir_name) + if os.path.exists(target_dir): + if force: + shutil.rmtree(target_dir) + else: + raise CommandError( + f'插件目录已存在: {target_dir}\n' + f'使用 --force 强制重新安装' + ) + + self.stdout.write(f'正在从 zip 文件解压插件: {zip_path}') + + with tempfile.TemporaryDirectory() as tmp_dir: + with zipfile.ZipFile(zip_path, 'r') as zf: + zf.extractall(tmp_dir) + + extracted_items = os.listdir(tmp_dir) + if len(extracted_items) == 1 and os.path.isdir( + os.path.join(tmp_dir, extracted_items[0]) + ): + src_dir = os.path.join(tmp_dir, extracted_items[0]) + inner_items = os.listdir(src_dir) + has_init = '__init__.py' in inner_items + if has_init: + plugin_dir_name = extracted_items[0] + target_dir = os.path.join(plugins_base, plugin_dir_name) + if os.path.exists(target_dir): + if force: + shutil.rmtree(target_dir) + else: + raise CommandError( + f'插件目录已存在: {target_dir}\n' + f'使用 --force 强制重新安装' + ) + shutil.copytree(src_dir, target_dir) + else: + sub_dirs = [ + d for d in inner_items + if os.path.isdir(os.path.join(src_dir, d)) + and os.path.exists(os.path.join(src_dir, d, '__init__.py')) + ] + if len(sub_dirs) == 1: + plugin_dir_name = sub_dirs[0] + target_dir = os.path.join(plugins_base, plugin_dir_name) + if os.path.exists(target_dir): + if force: + shutil.rmtree(target_dir) + else: + raise CommandError( + f'插件目录已存在: {target_dir}\n' + f'使用 --force 强制重新安装' + ) + shutil.copytree( + os.path.join(src_dir, sub_dirs[0]), target_dir + ) + else: + shutil.copytree(src_dir, target_dir) + else: + shutil.copytree(tmp_dir, target_dir) + + self.stdout.write(self.style.SUCCESS( + f'插件已解压到: {target_dir}' + )) + + app_label = self.install_from_path(target_dir) + return app_label + def install_from_registry(self, plugin_name, registry_url=None, force=False): remote_plugins = self._fetch_registry(registry_url) @@ -393,10 +708,52 @@ def _load_plugin_class_from_package(self, plugin_id, plugin_path): plugin_module_name = mod_name if self.debug: self.stdout.write(f'[DEBUG] 从 PLUGIN_INFO 找到插件类: {plugin_class.__name__}') + + if not plugin_class: + for attr_name in dir(init_module): + attr = getattr(init_module, attr_name) + if (inspect.isclass(attr) and + hasattr(attr, '__mro__') and + attr.__name__ != 'PluginInterface' and + any(hasattr(b, '__name__') and b.__name__ == 'PluginInterface' + for b in attr.__mro__)): + plugin_class = attr + plugin_module_name = mod_name + break except ImportError as e: if self.debug: self.stdout.write(f'[DEBUG] import_module({mod_name}) 失败: {e}') + try: + spec = importlib.util.spec_from_file_location( + mod_name, init_file + ) + if spec is not None and spec.loader is not None: + init_module = importlib.util.module_from_spec(spec) + sys.modules[mod_name] = init_module + spec.loader.exec_module(init_module) + + if hasattr(init_module, 'PLUGIN_INFO'): + pinfo = getattr(init_module, 'PLUGIN_INFO') + if 'main_class' in pinfo and hasattr(init_module, pinfo['main_class']): + plugin_class = getattr(init_module, pinfo['main_class']) + plugin_module_name = mod_name + + if not plugin_class: + for attr_name in dir(init_module): + attr = getattr(init_module, attr_name) + if (inspect.isclass(attr) and + hasattr(attr, '__mro__') and + attr.__name__ != 'PluginInterface' and + any(hasattr(b, '__name__') and b.__name__ == 'PluginInterface' + for b in attr.__mro__)): + plugin_class = attr + plugin_module_name = mod_name + break + except Exception as e2: + if self.debug: + self.stdout.write(f'[DEBUG] spec_from_file_location 也失败: {e2}') + if not plugin_class: plugin_class, plugin_module_name = self._scan_py_files_for_plugin( plugin_id, plugin_path @@ -459,13 +816,16 @@ def install_from_path(self, plugin_path): if not os.path.exists(plugin_path): raise CommandError(f'插件路径不存在: {plugin_path}') - plugin_dir_name = os.path.basename(plugin_path) + plugin_dir_name = os.path.basename(os.path.abspath(plugin_path)) plugins_base = os.path.join(settings.BASE_DIR, 'plugins') is_under_plugins = ( os.path.dirname(os.path.abspath(plugin_path)) == os.path.abspath(plugins_base) ) + plugin_manager = get_plugin_manager() + loaded_plugins = plugin_manager.get_all_plugins() + plugin_class = None plugin_module_name = None @@ -482,24 +842,39 @@ def install_from_path(self, plugin_path): if not plugin_class: raise CommandError( - f'在 {plugin_path} 中未找到有效的插件类' + f'在 {plugin_path} 中未找到有效的插件类\n' + f'请确保插件目录中包含继承自 PluginInterface 的类' ) try: plugin_instance = plugin_class() - plugin_manager = get_plugin_manager() + + if plugin_instance.plugin_id in loaded_plugins: + self.stdout.write(self.style.WARNING( + f'插件 {plugin_instance.name} (ID: {plugin_instance.plugin_id}) 已加载,跳过重复安装' + )) + return self._get_app_label_from_module(plugin_module_name) plugin_manager.plugins[plugin_instance.plugin_id] = ( plugin_instance ) - if plugin_instance.initialize(): - self.stdout.write(self.style.SUCCESS( - f'成功从路径安装插件: {plugin_instance.name}' - )) - else: + from plugins.core.base import ServiceProvider + if isinstance(plugin_instance, ServiceProvider): + plugin_manager.service_registry.register(plugin_instance) + + try: + if plugin_instance.initialize(): + self.stdout.write(self.style.SUCCESS( + f'成功从路径安装插件: {plugin_instance.name}' + )) + else: + self.stdout.write(self.style.WARNING( + f'插件 {plugin_instance.name} 安装成功但初始化失败' + )) + except Exception as e: self.stdout.write(self.style.WARNING( - f'插件 {plugin_instance.name} 安装成功但初始化失败' + f'插件 {plugin_instance.name} 初始化出错: {str(e)}' )) plugin_record, created = PluginRecord.objects.update_or_create( @@ -513,7 +888,9 @@ def install_from_path(self, plugin_path): ) if created: - self.stdout.write(f'已创建插件数据库记录') + self.stdout.write('已创建插件数据库记录') + else: + self.stdout.write('已更新插件数据库记录') self.add_plugin_to_toml_config(plugin_instance.plugin_id, { 'name': plugin_instance.name, @@ -523,7 +900,17 @@ def install_from_path(self, plugin_path): 'version': plugin_instance.version, 'enabled': True }) + + self.stdout.write(self.style.SUCCESS( + f'插件 {plugin_instance.name} (v{plugin_instance.version}) 安装完成!' + )) + self.stdout.write( + '提示: 需要重启服务以使 Django App 注册生效' + ) + return self._get_app_label_from_module(plugin_module_name) + except CommandError: + raise except Exception as e: raise CommandError(f'从路径安装插件失败: {str(e)}') diff --git a/plugins/plugin_manager.py b/plugins/plugin_manager.py index b09bfd7..f53318f 100755 --- a/plugins/plugin_manager.py +++ b/plugins/plugin_manager.py @@ -7,10 +7,13 @@ import sys import importlib import importlib.util +import logging from typing import Any, Dict, List, Optional, Type from plugins.core.base import PluginInterface, EventHook +logger = logging.getLogger(__name__) + class PluginManager: """ @@ -49,7 +52,7 @@ def load_plugins_from_directory(self, directory: str) -> List[str]: loaded_plugins = [] if not os.path.isdir(directory): - print(f"Plugin directory does not exist: {directory}") + logger.warning(f"Plugin directory does not exist: {directory}") return loaded_plugins NON_PLUGIN_FILES = frozenset([ @@ -90,7 +93,7 @@ def load_plugins_from_directory(self, directory: str) -> List[str]: except ImportError: pass except Exception as e: - print(f"Error loading plugin from {item_path}: {str(e)}") + logger.error(f"Error loading plugin from {item_path}: {str(e)}") for subdir in os.listdir(directory): subdir_path = os.path.join(directory, subdir) @@ -124,12 +127,12 @@ def _load_subdir_package( self._extract_and_register_plugins(module, subdir) ) except ImportError as e: - print( + logger.error( f"Error importing plugin package " f"{package_name}: {str(e)}" ) except Exception as e: - print( + logger.error( f"Error loading plugin package " f"{package_name}: {str(e)}" ) @@ -157,7 +160,7 @@ def _extract_and_register_plugins( if self.register_plugin(plugin_instance): loaded.append(plugin_instance.plugin_id) except Exception as e: - print( + logger.error( f"Error instantiating plugin " f"{attr_name}: {str(e)}" ) @@ -170,14 +173,14 @@ def register_plugin(self, plugin: PluginInterface) -> bool: :return: 注册是否成功 """ if plugin.plugin_id in self.plugins: - print(f"Plugin with ID {plugin.plugin_id} already exists") + logger.warning(f"Plugin with ID {plugin.plugin_id} already exists") return False try: # 初始化插件 if plugin.initialize(): self.plugins[plugin.plugin_id] = plugin - print(f"Successfully registered plugin: {plugin.name} ({plugin.plugin_id})") + logger.info(f"Successfully registered plugin: {plugin.name} ({plugin.plugin_id})") # 同步到数据库(如果Django可用) plugin_model = self._get_plugin_model() @@ -196,14 +199,14 @@ def register_plugin(self, plugin: PluginInterface) -> bool: } ) except Exception as db_error: - print(f"Error syncing plugin to database: {str(db_error)}") + logger.error(f"Error syncing plugin to database: {str(db_error)}") return True else: - print(f"Failed to initialize plugin: {plugin.name}") + logger.warning(f"Failed to initialize plugin: {plugin.name}") return False except Exception as e: - print(f"Error initializing plugin {plugin.name}: {str(e)}") + logger.error(f"Error initializing plugin {plugin.name}: {str(e)}") return False def unregister_plugin(self, plugin_id: str) -> bool: @@ -213,7 +216,7 @@ def unregister_plugin(self, plugin_id: str) -> bool: :return: 卸载是否成功 """ if plugin_id not in self.plugins: - print(f"Plugin with ID {plugin_id} does not exist") + logger.warning(f"Plugin with ID {plugin_id} does not exist") return False plugin = self.plugins[plugin_id] @@ -222,14 +225,14 @@ def unregister_plugin(self, plugin_id: str) -> bool: # 关闭插件 if plugin.shutdown(): del self.plugins[plugin_id] - print(f"Successfully unregistered plugin: {plugin.name}") + logger.info(f"Successfully unregistered plugin: {plugin.name}") return True else: - print(f"Failed to shutdown plugin: {plugin.name}") + logger.warning(f"Failed to shutdown plugin: {plugin.name}") return False except Exception as e: - print(f"Error shutting down plugin {plugin.name}: {str(e)}") + logger.error(f"Error shutting down plugin {plugin.name}: {str(e)}") return False def enable_plugin(self, plugin_id: str) -> bool: @@ -244,9 +247,9 @@ def enable_plugin(self, plugin_id: str) -> bool: # 使用 update 方法直接更新数据库,避免触发信号 rows_updated = plugin_model.objects.filter(plugin_id=plugin_id).update(is_active=True) if rows_updated > 0: - print(f"Database updated for plugin {plugin_id} (enabled)") + logger.info(f"Database updated for plugin {plugin_id} (enabled)") except Exception as db_error: - print(f"Error updating plugin status in database: {str(db_error)}") + logger.error(f"Error updating plugin status in database: {str(db_error)}") return True return False @@ -263,9 +266,9 @@ def disable_plugin(self, plugin_id: str) -> bool: # 使用 update 方法直接更新数据库,避免触发信号 rows_updated = plugin_model.objects.filter(plugin_id=plugin_id).update(is_active=False) if rows_updated > 0: - print(f"Database updated for plugin {plugin_id} (disabled)") + logger.info(f"Database updated for plugin {plugin_id} (disabled)") except Exception as db_error: - print(f"Error updating plugin status in database: {str(db_error)}") + logger.error(f"Error updating plugin status in database: {str(db_error)}") return True return False @@ -302,7 +305,7 @@ def load_all_plugins_from_directory(self, directory: str) -> List[str]: loaded_plugins = [] if not os.path.isdir(directory): - print(f"Plugin directory does not exist: {directory}") + logger.warning(f"Plugin directory does not exist: {directory}") return loaded_plugins for item in os.listdir(directory): @@ -343,9 +346,9 @@ def load_all_plugins_from_directory(self, directory: str) -> List[str]: loaded_plugins.append(plugin_instance.plugin_id) except ImportError as e: - print(f"Failed to import plugin module from {item_path}: {str(e)}") + logger.error(f"Failed to import plugin module from {item_path}: {str(e)}") except Exception as e: - print(f"Error loading plugin from {item_path}: {str(e)}") + logger.error(f"Error loading plugin from {item_path}: {str(e)}") return loaded_plugins diff --git a/plugins/test_demo_plugin/__init__.py b/plugins/test_demo_plugin/__init__.py new file mode 100644 index 0000000..3c07b3e --- /dev/null +++ b/plugins/test_demo_plugin/__init__.py @@ -0,0 +1,16 @@ +from plugins.core.base import PluginInterface + +class TestDemoPlugin(PluginInterface): + def __init__(self): + super().__init__( + plugin_id='test_demo', + name='Test Demo Plugin', + version='0.1.0', + description='A test plugin for verifying zip installation', + ) + + def initialize(self) -> bool: + return True + + def shutdown(self) -> bool: + return True diff --git a/plugins/views.py b/plugins/views.py index 85ec06b..5767d5c 100755 --- a/plugins/views.py +++ b/plugins/views.py @@ -11,6 +11,7 @@ from . import plugin_manager +@login_required def plugin_list(request): """ 插件列表视图 @@ -22,6 +23,7 @@ def plugin_list(request): return render(request, 'plugins/list.html', context) +@login_required def plugin_detail(request, plugin_id): """ 插件详情视图 diff --git a/pyproject.bak.20260530_170442.toml b/pyproject.bak.20260530_170442.toml new file mode 100644 index 0000000..5cbf1be --- /dev/null +++ b/pyproject.bak.20260530_170442.toml @@ -0,0 +1,102 @@ +[project] +name = "2c2a" +version = "1.0.0" +description = "2c2a - Django Web Application" +license = "AGPL-3.0-only" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "amqp==5.3.1", + "asgiref==3.8.1", + "billiard==4.2.4", + "celery==5.4.0", + "certifi==2026.1.4", + "cffi==2.0.0", + "charset-normalizer==3.4.4", + "click==8.3.1", + "click-didyoumean==0.3.1", + "click-plugins==1.1.1.2", + "click-repl==0.3.0", + "cryptography==46.0.7", + "Django==4.2.30", + "django-cors-headers==4.3.1", + "djangorestframework==3.15.2", + "idna==3.11", + "kombu==5.6.2", + "Markdown==3.10.1", + "packaging==26.0", + "pillow==12.2.0", + "prompt_toolkit==3.0.52", + "pycparser==3.0", + "pyspnego==0.12.0", + "python-dateutil==2.9.0.post0", + "python-dotenv==1.2.2", + "pywinrm==0.4.3", + "requests==2.33.0", + "requests_ntlm==1.3.0", + "six==1.17.0", + "sqlalchemy>=2.0.0", + "sqlparse==0.5.4", + "msgpack>=1.0.0", + "typing_extensions>=4.15.0", + "tzdata==2025.3", + "urllib3==2.7.0", + "vine==5.1.0", + "wcwidth==0.3.2", + "xmltodict==1.0.2", + "pyotp", + "toml", + "black>=26.3.1", + "pymysql>=1.1.2", + "django-cotton @ git+https://github.com/2c2a/django-cotton.git@feature/x-prefix-tag-support", + "django-formtools>=2.5.1", + "django-tianai-captcha @ git+https://github.com/trustedinster/django-tianai-captcha.git@master", + "PyJWT>=2.8.0", +] + +[project.optional-dependencies] +redis = [ + "redis>=5.0.0", +] +postgresql = [ + "psycopg2-binary>=2.9.9", +] +kerberos = [ + "gssapi>=1.11.1", + "krb5>=0.9.0", +] +dev = [ + "pytest", + "pytest-django", + "black", + "flake8", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[dependency-groups] +dev = [ + "pytest", + "pytest-django", + "black", + "flake8", + "django-stubs>=6.0.2", + "pyrefly>=0.60.0", +] + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["."] + +[tool.pyright] +venvPath = "." +venv = ".venv" +pythonVersion = "3.13" +typeCheckingMode = "basic" + +[tool.pyrefly] +python-version = "3.13" diff --git a/pyproject.toml b/pyproject.toml index 56202c6..a23ce07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,70 +6,36 @@ license = "AGPL-3.0-only" readme = "README.md" requires-python = ">=3.10" dependencies = [ - "amqp==5.3.1", - "asgiref==3.8.1", - "billiard==4.2.4", "celery==5.4.0", - "certifi==2026.1.4", - "cffi==2.0.0", - "charset-normalizer==3.4.4", - "click==8.3.1", - "click-didyoumean==0.3.1", - "click-plugins==1.1.1.2", - "click-repl==0.3.0", - "cryptography==46.0.7", - "Django==4.2.30", + "cryptography==46.0.3", "django-cors-headers==4.3.1", + "django-formtools>=2.5.1", + "Django==4.2.27", "djangorestframework==3.15.2", "idna==3.15", "kombu==5.6.2", "Markdown==3.10.1", - "packaging==26.0", - "pillow==12.2.0", - "prompt_toolkit==3.0.52", - "pycparser==3.0", - "pyspnego==0.12.0", - "python-dateutil==2.9.0.post0", - "python-dotenv==1.2.2", - "pywinrm==0.4.3", - "requests==2.33.0", - "requests_ntlm==1.3.0", - "six==1.17.0", - "sqlalchemy>=2.0.0", - "sqlparse==0.5.4", - "msgpack>=1.0.0", - "typing_extensions>=4.15.0", - "tzdata==2025.3", - "urllib3==2.7.0", - "vine==5.1.0", - "wcwidth==0.3.2", - "xmltodict==1.0.2", + "pillow==12.1.0", + "PyJWT>=2.8.0", "pyotp", + "python-dotenv==1.2.1", + "pywinrm==0.4.3", + "redis>=5.0.0", + "requests==2.32.3", "toml", - "black>=26.3.1", - "pymysql>=1.1.2", "django-cotton @ git+https://github.com/2c2a/django-cotton.git@feature/x-prefix-tag-support", - "django-formtools>=2.5.1", - "PyJWT>=2.8.0", + "djlint>=1.36.4", + "heroicons>=2.14.0", + "cotton-icons>=0.2.0", + "whitenoise>=6.12.0", + "django-tianai-captcha", ] [project.optional-dependencies] -redis = [ - "redis>=5.0.0", -] -postgresql = [ - "psycopg2-binary>=2.9.9", -] kerberos = [ "gssapi>=1.11.1", "krb5>=0.9.0", ] -dev = [ - "pytest", - "pytest-django", - "black", - "flake8", -] [build-system] requires = ["hatchling"] @@ -80,9 +46,11 @@ dev = [ "pytest>=9.0.3", "pytest-django", "black", - "flake8", "django-stubs>=6.0.2", + "flake8", "pyrefly>=0.60.0", + "pytest", + "pytest-django", "redis>=7.4.0", ] @@ -108,3 +76,6 @@ typeCheckingMode = "basic" [tool.pyrefly] python-version = "3.13" + +[tool.uv.sources] +django-tianai-captcha = { git = "https://github.com/trustedinster/django-tianai-captcha.git" } diff --git a/scripts/deploy.py b/scripts/deploy.py index d037400..2039406 100755 --- a/scripts/deploy.py +++ b/scripts/deploy.py @@ -1,18 +1,19 @@ #!/usr/bin/env python3 """ -ZASCA 交互式部署脚本 +2c2a 交互式部署脚本 根据部署环境动态生成 pyproject.toml,仅安装所需依赖,避免冗余库 用法: python3 scripts/deploy.py """ -import os import sys import shutil import subprocess import curses from pathlib import Path +import secrets +import string from datetime import datetime BASE_DIR = Path(__file__).resolve().parent.parent @@ -499,7 +500,7 @@ def _page_progress(stdscr, step, total_steps, message): stdscr.refresh() -def _page_result(stdscr, success, answers, backup_name=None): +def _page_result(stdscr, success, answers, backup_name=None, env_configured=False, env_backup_name=None, migrate_success=False): _init_colors() stdscr.clear() max_y, max_x = stdscr.getmaxyx() @@ -512,16 +513,26 @@ def _page_result(stdscr, success, answers, backup_name=None): if success: title = " ✔ 部署完成 " - steps = [ - "1. 配置 .env 文件 (参考 .env.example)", - "2. 初始化数据库 uv run python manage.py migrate", - "3. 创建管理员 uv run python manage.py createsuperuser", - "4. 启动服务 uv run python manage.py runserver", - ] + steps = [] + n = 1 + if env_configured: + steps.append(f"{n}. .env 已配置 (可手动编辑调整)") + else: + steps.append(f"{n}. 配置 .env 文件 (参考 .env.example)") + n += 1 + if migrate_success: + steps.append(f"{n}. 数据库迁移 已完成") + else: + steps.append(f"{n}. 初始化数据库 uv run python manage.py migrate") + n += 1 + steps.append(f"{n}. 创建管理员 uv run python manage.py createsuperuser") + n += 1 + steps.append(f"{n}. 启动服务 uv run python manage.py runserver") if answers.get("celery"): - steps.append("5. 启动 Celery uv run celery -A config worker -l info") + n += 1 + steps.append(f"{n}. 启动 Celery uv run celery -A config worker -l info") - box_h = 4 + len(steps) + (2 if not answers.get("redis") and answers.get("celery") else 0) + (1 if backup_name else 0) + box_h = 4 + len(steps) + (2 if not answers.get("redis") and answers.get("celery") else 0) + (1 if backup_name else 0) + (1 if env_backup_name else 0) box_h = max(box_h, 8) _draw_box(stdscr, box_y, box_x, box_h, box_w) @@ -538,6 +549,9 @@ def _page_result(stdscr, success, answers, backup_name=None): if backup_name: _safe_addstr(stdscr, row, box_x + 3, f"备份: {backup_name}", curses.color_pair(C_DESC)) + row += 1 + if env_backup_name: + _safe_addstr(stdscr, row, box_x + 3, f".env 备份: {env_backup_name}", curses.color_pair(C_DESC)) else: box_h = 8 _draw_box(stdscr, box_y, box_x, box_h, box_w) @@ -583,11 +597,11 @@ def _tui_main(stdscr): backup = backup_file(toml_path) backup_name = backup.name if backup else None - _page_progress(stdscr, 1, 2, "正在生成 pyproject.toml ...") + _page_progress(stdscr, 1, 3, "正在生成 pyproject.toml ...") content = generate_pyproject_toml(answers, deps, dev_deps) toml_path.write_text(content, encoding="utf-8") - _page_progress(stdscr, 2, 2, "正在执行 uv sync ...") + _page_progress(stdscr, 2, 3, "正在执行 uv sync ...") try: proc = subprocess.run( ["uv", "sync"], @@ -602,7 +616,807 @@ def _tui_main(stdscr): except subprocess.TimeoutExpired: success = False - _page_result(stdscr, success, answers, backup_name) + env_configured = False + env_backup_name = None + migrate_success = False + if success: + env_values = _configure_env(stdscr, answers) + if env_values is not None: + env_path = BASE_DIR / ".env" + backup_env = backup_file(env_path) + env_backup_name = backup_env.name if backup_env else None + env_content = generate_env_content(answers, env_values) + env_path.write_text(env_content, encoding="utf-8") + env_configured = True + + _page_progress(stdscr, 3, 3, "正在执行数据库迁移 ...") + try: + migrate_proc = subprocess.run( + ["uv", "run", "python", "manage.py", "migrate"], + cwd=BASE_DIR, + capture_output=True, + text=True, + timeout=120, + ) + migrate_success = migrate_proc.returncode == 0 + except FileNotFoundError: + migrate_success = False + except subprocess.TimeoutExpired: + migrate_success = False + + _page_result(stdscr, success, answers, backup_name, env_configured, env_backup_name, migrate_success) + + +def _generate_secret(length=50): + alphabet = string.ascii_letters + string.digits + "!@#$%^&*-_=+" + return ''.join(secrets.choice(alphabet) for _ in range(length)) + + +def _load_existing_env(env_path): + values = {} + if env_path.exists(): + for line in env_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith('#'): + continue + if '=' in line: + key, _, val = line.partition('=') + values[key.strip()] = val.strip() + return values + + +def _get_env_items(answers): + db = answers.get("db", "sqlite") + db_port_default = {"mysql": "3306", "postgresql": "5432"}.get(db, "3306") + db_user_default = {"mysql": "root", "postgresql": "postgres"}.get(db, "root") + + items = [] + + items.append(("group", "核心配置")) + items.append(("field", {"key": "DEBUG", "default": "True", "desc": "调试模式(生产环境必须 False)", "type": "bool"})) + items.append(("field", {"key": "DJANGO_SECRET_KEY", "default": "", "desc": "Django 密钥", "type": "secret"})) + items.append(("field", {"key": "ALLOWED_HOSTS", "default": "localhost,127.0.0.1", "desc": "允许访问的主机", "type": "list"})) + items.append(("field", {"key": "CSRF_TRUSTED_ORIGINS", "default": "https://localhost,https://127.0.0.1", "desc": "CSRF 可信来源", "type": "list"})) + + items.append(("group", "数据库配置")) + items.append(("field", {"key": "DB_ENGINE", "default": db, "desc": "数据库引擎(根据之前的选择自动设置)", "type": "auto"})) + if db != "sqlite": + items.append(("field", {"key": "DB_HOST", "default": "127.0.0.1", "desc": "数据库主机", "type": "text"})) + items.append(("field", {"key": "DB_PORT", "default": db_port_default, "desc": "数据库端口", "type": "text"})) + items.append(("field", {"key": "DB_NAME", "default": "zasca", "desc": "数据库名称", "type": "text"})) + items.append(("field", {"key": "DB_USER", "default": db_user_default, "desc": "数据库用户", "type": "text"})) + items.append(("field", {"key": "DB_PASSWORD", "default": "", "desc": "数据库密码", "type": "secret"})) + + if answers.get("redis"): + items.append(("group", "Redis 配置")) + items.append(("field", {"key": "REDIS_URL", "default": "redis://localhost:6379/0", "desc": "Redis 连接地址", "type": "text"})) + + if answers.get("celery"): + items.append(("group", "Celery 配置")) + items.append(("field", {"key": "CELERY_BROKER_URL", "default": "", "desc": "Celery Broker(留空自动选择)", "type": "text"})) + items.append(("field", {"key": "CELERY_RESULT_BACKEND", "default": "", "desc": "Celery 结果后端(留空自动选择)", "type": "text"})) + + items.append(("group", "演示模式")) + items.append(("field", {"key": "ZASCA_DEMO", "default": "0", "desc": "演示模式(1=启用)", "type": "text"})) + + items.append(("group", "安全配置")) + items.append(("field", {"key": "SECURE_SSL_REDIRECT", "default": "False", "desc": "SSL 重定向", "type": "bool"})) + items.append(("field", {"key": "SESSION_COOKIE_SECURE", "default": "False", "desc": "会话 Cookie 安全", "type": "bool"})) + items.append(("field", {"key": "CSRF_COOKIE_SECURE", "default": "False", "desc": "CSRF Cookie 安全", "type": "bool"})) + + items.append(("group", "日志配置")) + items.append(("field", {"key": "LOG_LEVEL", "default": "DEBUG", "desc": "日志级别", "type": "text"})) + items.append(("field", {"key": "LOG_FILE", "default": "/var/log/2c2a/application.log", "desc": "日志文件路径", "type": "text"})) + + if answers.get("winrm"): + items.append(("group", "WinRM 配置")) + items.append(("field", {"key": "WINRM_TIMEOUT", "default": "30", "desc": "WinRM 超时(秒)", "type": "text"})) + items.append(("field", {"key": "WINRM_RETRY_COUNT", "default": "3", "desc": "WinRM 重试次数", "type": "text"})) + + items.append(("group", "Gateway 配置")) + items.append(("field", {"key": "GATEWAY_ENABLED", "default": "False", "desc": "Gateway 开关", "type": "bool"})) + items.append(("field", {"key": "GATEWAY_CONTROL_SOCKET", "default": "/run/2c2a/control.sock", "desc": "Gateway 控制套接字", "type": "text"})) + + items.append(("group", "Beta 数据库配置(可选)")) + items.append(("field", {"key": "BETA_DB_NAME", "default": "", "desc": "Beta 数据库名称(留空跳过)", "type": "text"})) + items.append(("field", {"key": "BETA_DB_USER", "default": "", "desc": "Beta 数据库用户", "type": "text"})) + items.append(("field", {"key": "BETA_DB_PASSWORD", "default": "", "desc": "Beta 数据库密码", "type": "secret"})) + items.append(("field", {"key": "BETA_DB_HOST", "default": "", "desc": "Beta 数据库主机", "type": "text"})) + items.append(("field", {"key": "BETA_DB_PORT", "default": "", "desc": "Beta 数据库端口", "type": "text"})) + + items.append(("group", "Bootstrap 认证配置")) + items.append(("field", {"key": "BOOTSTRAP_SHARED_SALT", "default": "", "desc": "Bootstrap 共享盐值", "type": "secret"})) + + return items + + +def _step_bool(stdscr, field_data, current_val, step, total): + key = field_data["key"] + desc = field_data.get("desc", "") + selected = 0 if current_val == "True" else 1 + + while True: + _init_colors() + stdscr.clear() + max_y, max_x = stdscr.getmaxyx() + + box_w = min(72, max_x - 4) + box_h = 10 + box_x = max(0, (max_x - box_w) // 2) + box_y = 2 + + _draw_banner(stdscr, 0, max_x) + _draw_box(stdscr, box_y, box_x, box_h, box_w) + + title = f" 环境变量配置 ({step}/{total}) " + tx = box_x + max(0, (box_w - len(title)) // 2) + _safe_addstr(stdscr, box_y, tx, title, curses.color_pair(C_TITLE) | curses.A_BOLD) + + ry = box_y + 2 + _safe_addstr(stdscr, ry, box_x + 4, key, curses.color_pair(C_SELECTED) | curses.A_BOLD) + + ry += 1 + if desc: + _safe_addstr(stdscr, ry, box_x + 4, desc[:box_w - 8], curses.color_pair(C_DESC)) + + ry += 2 + options = [("True", "启用"), ("False", "禁用")] + for i, (val, label) in enumerate(options): + is_sel = (i == selected) + marker = "◉" if is_sel else "○" + m_color = C_RADIO_ON | curses.A_BOLD if is_sel else C_RADIO_OFF + ox = box_x + 6 + i * 22 + _safe_addstr(stdscr, ry, ox, marker, curses.color_pair(m_color)) + _safe_addstr(stdscr, ry, ox + 2, f" {label} ({val})", curses.color_pair(C_SELECTED if is_sel else C_UNSELECTED) | curses.A_BOLD) + + hint_y = box_y + box_h + 1 + _draw_hint(stdscr, hint_y, box_x + 2, "←→/Space 切换 Enter 确认 Esc 返回") + + stdscr.refresh() + + ch = stdscr.getch() + if ch == curses.KEY_LEFT: + selected = 0 + elif ch == curses.KEY_RIGHT: + selected = 1 + elif ch == ord(' '): + selected = 1 - selected + elif ch in (curses.KEY_ENTER, 10, 13): + return "True" if selected == 0 else "False" + elif ch == 27: + return None + + +def _step_secret(stdscr, field_data, current_val, is_auto, step, total): + key = field_data["key"] + desc = field_data.get("desc", "") + + if is_auto or not current_val: + radio = 0 + else: + radio = 1 + + while True: + _init_colors() + stdscr.clear() + max_y, max_x = stdscr.getmaxyx() + + box_w = min(72, max_x - 4) + box_h = 12 + box_x = max(0, (max_x - box_w) // 2) + box_y = 2 + + _draw_banner(stdscr, 0, max_x) + _draw_box(stdscr, box_y, box_x, box_h, box_w) + + title = f" 环境变量配置 ({step}/{total}) " + tx = box_x + max(0, (box_w - len(title)) // 2) + _safe_addstr(stdscr, box_y, tx, title, curses.color_pair(C_TITLE) | curses.A_BOLD) + + ry = box_y + 2 + _safe_addstr(stdscr, ry, box_x + 4, key, curses.color_pair(C_SELECTED) | curses.A_BOLD) + + ry += 1 + if desc: + _safe_addstr(stdscr, ry, box_x + 4, desc[:box_w - 8], curses.color_pair(C_DESC)) + + ry += 2 + is_sel0 = (radio == 0) + marker0 = "◉" if is_sel0 else "○" + m_color0 = C_RADIO_ON | curses.A_BOLD if is_sel0 else C_RADIO_OFF + _safe_addstr(stdscr, ry, box_x + 4, marker0, curses.color_pair(m_color0)) + _safe_addstr(stdscr, ry, box_x + 7, "随机生成(推荐)", curses.color_pair(C_SELECTED if is_sel0 else C_UNSELECTED) | curses.A_BOLD) + + ry += 1 + sub_desc = "自动生成包含字母、数字和符号的50位密钥" + _safe_addstr(stdscr, ry, box_x + 7, sub_desc[:box_w - 10], curses.color_pair(C_DESC)) + + if is_sel0 and is_auto and current_val: + ry += 1 + preview = current_val[:8] + "..." + current_val[-4:] if len(current_val) > 12 else current_val + _safe_addstr(stdscr, ry, box_x + 7, f"预览: {preview}", curses.color_pair(C_DESC)) + + ry += 2 + is_sel1 = (radio == 1) + marker1 = "◉" if is_sel1 else "○" + m_color1 = C_RADIO_ON | curses.A_BOLD if is_sel1 else C_RADIO_OFF + _safe_addstr(stdscr, ry, box_x + 4, marker1, curses.color_pair(m_color1)) + _safe_addstr(stdscr, ry, box_x + 7, "手动输入", curses.color_pair(C_SELECTED if is_sel1 else C_UNSELECTED) | curses.A_BOLD) + + hint_y = box_y + box_h + 1 + _draw_hint(stdscr, hint_y, box_x + 2, "↑↓ 选择 Enter 确认 Esc 返回") + + stdscr.refresh() + + ch = stdscr.getch() + if ch == curses.KEY_UP: + radio = 0 + elif ch == curses.KEY_DOWN: + radio = 1 + elif ch in (curses.KEY_ENTER, 10, 13): + if radio == 0: + if not current_val or not is_auto: + current_val = _generate_secret(50) + return current_val, True + else: + result = _step_text(stdscr, field_data, current_val, step, total) + if result is not None: + return result, False + continue + elif ch == 27: + return None, None + + +def _step_text(stdscr, field_data, current_val, step, total): + key = field_data["key"] + desc = field_data.get("desc", "") + curses.curs_set(1) + buf = list(current_val) + pos = len(buf) + + while True: + _init_colors() + stdscr.clear() + max_y, max_x = stdscr.getmaxyx() + + box_w = min(72, max_x - 4) + box_h = 10 + box_x = max(0, (max_x - box_w) // 2) + box_y = 2 + + _draw_banner(stdscr, 0, max_x) + _draw_box(stdscr, box_y, box_x, box_h, box_w) + + title = f" 环境变量配置 ({step}/{total}) " + tx = box_x + max(0, (box_w - len(title)) // 2) + _safe_addstr(stdscr, box_y, tx, title, curses.color_pair(C_TITLE) | curses.A_BOLD) + + ry = box_y + 2 + _safe_addstr(stdscr, ry, box_x + 4, key, curses.color_pair(C_SELECTED) | curses.A_BOLD) + + ry += 1 + if desc: + _safe_addstr(stdscr, ry, box_x + 4, desc[:box_w - 8], curses.color_pair(C_DESC)) + + ry += 2 + input_x = box_x + 4 + input_w = box_w - 8 + _safe_addstr(stdscr, ry, input_x, " " * input_w, curses.color_pair(C_HIGHLIGHT)) + + text = "".join(buf) + if len(text) > input_w: + start = max(0, pos - input_w + 1) + visible_text = text[start:start + input_w] + cursor_offset = pos - start + else: + visible_text = text + cursor_offset = pos + + _safe_addstr(stdscr, ry, input_x, visible_text[:input_w], curses.color_pair(C_HIGHLIGHT)) + + try: + stdscr.move(ry, input_x + min(cursor_offset, input_w - 1)) + except curses.error: + # Cursor move can fail when terminal is resized or too small; ignore and continue rendering. + pass + + hint_y = box_y + box_h + 1 + _draw_hint(stdscr, hint_y, box_x + 2, "Enter 确认 Esc 返回 Ctrl+U 清空") + + stdscr.refresh() + + ch = stdscr.getch() + if ch in (curses.KEY_ENTER, 10, 13): + curses.curs_set(0) + return "".join(buf) + elif ch == 27: + curses.curs_set(0) + return None + elif ch in (curses.KEY_BACKSPACE, 127, 8): + if pos > 0: + buf.pop(pos - 1) + pos -= 1 + elif ch == curses.KEY_DC: + if pos < len(buf): + buf.pop(pos) + elif ch == curses.KEY_LEFT: + if pos > 0: + pos -= 1 + elif ch == curses.KEY_RIGHT: + if pos < len(buf): + pos += 1 + elif ch == curses.KEY_HOME: + pos = 0 + elif ch == curses.KEY_END: + pos = len(buf) + elif ch == 21: + buf.clear() + pos = 0 + elif 32 <= ch < 127: + buf.insert(pos, chr(ch)) + pos += 1 + + +def _step_auto(stdscr, field_data, current_val, step, total): + key = field_data["key"] + desc = field_data.get("desc", "") + + while True: + _init_colors() + stdscr.clear() + max_y, max_x = stdscr.getmaxyx() + + box_w = min(72, max_x - 4) + box_h = 10 + box_x = max(0, (max_x - box_w) // 2) + box_y = 2 + + _draw_banner(stdscr, 0, max_x) + _draw_box(stdscr, box_y, box_x, box_h, box_w) + + title = f" 环境变量配置 ({step}/{total}) " + tx = box_x + max(0, (box_w - len(title)) // 2) + _safe_addstr(stdscr, box_y, tx, title, curses.color_pair(C_TITLE) | curses.A_BOLD) + + ry = box_y + 2 + _safe_addstr(stdscr, ry, box_x + 4, key, curses.color_pair(C_SELECTED) | curses.A_BOLD) + + ry += 1 + if desc: + _safe_addstr(stdscr, ry, box_x + 4, desc[:box_w - 8], curses.color_pair(C_DESC)) + + ry += 2 + _safe_addstr(stdscr, ry, box_x + 4, f"▸ {current_val}", curses.color_pair(C_SUCCESS) | curses.A_BOLD) + + ry += 1 + _safe_addstr(stdscr, ry, box_x + 4, "(此值由之前的选择自动确定)", curses.color_pair(C_DESC)) + + hint_y = box_y + box_h + 1 + _draw_hint(stdscr, hint_y, box_x + 2, "Enter 继续 Esc 返回") + + stdscr.refresh() + + ch = stdscr.getch() + if ch in (curses.KEY_ENTER, 10, 13): + return current_val + elif ch == 27: + return None + + +def _step_list(stdscr, field_data, current_val, step, total): + key = field_data["key"] + desc = field_data.get("desc", "") + items = [x.strip() for x in current_val.split(",") if x.strip()] if current_val else [] + cursor = 0 + scroll = 0 + + while True: + _init_colors() + stdscr.clear() + max_y, max_x = stdscr.getmaxyx() + + box_w = min(72, max_x - 4) + box_h = max(14, min(20, max_y - 4)) + box_x = max(0, (max_x - box_w) // 2) + box_y = 1 + + _draw_banner(stdscr, 0, max_x) + _draw_box(stdscr, box_y, box_x, box_h, box_w) + + title = f" 环境变量配置 ({step}/{total}) " + tx = box_x + max(0, (box_w - len(title)) // 2) + _safe_addstr(stdscr, box_y, tx, title, curses.color_pair(C_TITLE) | curses.A_BOLD) + + ry = box_y + 2 + _safe_addstr(stdscr, ry, box_x + 4, key, curses.color_pair(C_SELECTED) | curses.A_BOLD) + + ry += 1 + if desc: + _safe_addstr(stdscr, ry, box_x + 4, desc[:box_w - 8], curses.color_pair(C_DESC)) + + ry += 2 + max_list_visible = box_h - 8 + max_scroll = max(0, len(items) - max_list_visible) + if cursor < scroll: + scroll = cursor + elif cursor >= scroll + max_list_visible: + scroll = cursor - max_list_visible + 1 + scroll = max(0, min(scroll, max_scroll)) + + if not items: + _safe_addstr(stdscr, ry, box_x + 6, "(空列表,按 + 添加项)", curses.color_pair(C_DESC)) + else: + visible_items = items[scroll:scroll + max_list_visible] + for i, item in enumerate(visible_items): + actual_idx = scroll + i + iy = ry + i + is_active = (actual_idx == cursor) + marker = "▸" if is_active else " " + line = f" {marker} {item}" + if is_active: + _safe_addstr(stdscr, iy, box_x + 4, line[:box_w - 8], curses.color_pair(C_HIGHLIGHT) | curses.A_BOLD) + else: + _safe_addstr(stdscr, iy, box_x + 4, line[:box_w - 8], curses.color_pair(C_UNSELECTED)) + + count_y = box_y + box_h - 3 + _safe_addstr(stdscr, count_y, box_x + 4, f"共 {len(items)} 项", curses.color_pair(C_DESC)) + + if max_scroll > 0: + scroll_info = f" [{scroll + 1}-{min(scroll + max_list_visible, len(items))}/{len(items)}] " + _safe_addstr(stdscr, count_y, box_x + box_w - len(scroll_info) - 2, scroll_info, curses.color_pair(C_DESC)) + + hint_y = box_y + box_h + 1 + _draw_hint(stdscr, hint_y, box_x + 2, "↑↓ 切换 + 添加 - 删除 Enter 编辑 Ctrl+D 完成 Esc 返回") + + stdscr.refresh() + + ch = stdscr.getch() + if ch == curses.KEY_UP: + if items: + cursor = max(0, cursor - 1) + elif ch == curses.KEY_DOWN: + if items: + cursor = min(len(items) - 1, cursor + 1) + elif ch in (ord('+'), ord('=')): + new_item = _step_text(stdscr, {"key": f"{key}[新项]", "desc": "输入新项的值"}, "", step, total) + if new_item is not None and new_item.strip(): + items.append(new_item.strip()) + cursor = len(items) - 1 + elif ch in (ord('-'), ord('_')): + if items and 0 <= cursor < len(items): + items.pop(cursor) + if cursor >= len(items) and len(items) > 0: + cursor = len(items) - 1 + elif ch in (curses.KEY_ENTER, 10, 13): + if items and 0 <= cursor < len(items): + new_item = _step_text(stdscr, {"key": f"{key}[{cursor}]", "desc": "编辑当前项"}, items[cursor], step, total) + if new_item is not None: + items[cursor] = new_item.strip() if new_item.strip() else items[cursor] + elif ch == 4: + return ",".join(items) + elif ch == 27: + return None + + +def _env_preview(stdscr, items, values, secret_auto): + field_indices = [i for i, (kind, _) in enumerate(items) if kind == "field"] + cursor = 0 + scroll = 0 + + while True: + _init_colors() + stdscr.clear() + max_y, max_x = stdscr.getmaxyx() + + box_w = min(76, max_x - 4) + box_h = max_y - 5 + box_x = max(0, (max_x - box_w) // 2) + box_y = 1 + + _draw_banner(stdscr, 0, max_x) + _draw_box(stdscr, box_y, box_x, box_h, box_w) + + title = " .env 配置预览 " + tx = box_x + max(0, (box_w - len(title)) // 2) + _safe_addstr(stdscr, box_y, tx, title, curses.color_pair(C_TITLE) | curses.A_BOLD) + + cursor_item_idx = field_indices[cursor] if cursor < len(field_indices) else 0 + + max_visible = box_h - 3 + total_items = len(items) + max_scroll = max(0, total_items - max_visible) + + if cursor_item_idx < scroll: + scroll = cursor_item_idx + elif cursor_item_idx >= scroll + max_visible: + scroll = cursor_item_idx - max_visible + 1 + scroll = max(0, min(scroll, max_scroll)) + + visible = items[scroll:scroll + max_visible] + current_visible_idx = cursor_item_idx - scroll + + for i, (kind, data) in enumerate(visible): + ry = box_y + 1 + i + rx = box_x + 2 + remaining = box_w - 5 + + if kind == "group": + _safe_addstr(stdscr, ry, rx, f" {data}", curses.color_pair(C_SUBTITLE) | curses.A_BOLD) + elif kind == "field": + is_active = (i == current_visible_idx) + key = data["key"] + val = values.get(key, data["default"]) + ftype = data.get("type", "text") + + if ftype == "secret": + if key in secret_auto: + if val: + preview = val[:6] + "..." + val[-4:] if len(val) > 10 else val + display = f"(随机: {preview})" + else: + display = "(随机生成)" + elif val: + display = "******" + else: + display = "(未设置)" + elif ftype == "auto": + display = f"{val} (自动)" + elif ftype == "bool": + display = val + elif ftype == "list": + list_items = [x.strip() for x in val.split(",") if x.strip()] if val else [] + if list_items: + display = ", ".join(list_items) + else: + display = "(空列表)" + else: + display = val if val else "(空)" + + line = f" {key} = {display}" + + if is_active: + _safe_addstr(stdscr, ry, rx, line[:remaining], curses.color_pair(C_HIGHLIGHT) | curses.A_BOLD) + else: + color = C_DESC if ftype == "auto" else C_UNSELECTED + _safe_addstr(stdscr, ry, rx, line[:remaining], curses.color_pair(color)) + + desc_y = box_y + box_h - 2 + if 0 <= current_visible_idx < len(visible) and visible[current_visible_idx][0] == "field": + field_data = visible[current_visible_idx][1] + desc = field_data.get("desc", "") + if desc: + _safe_addstr(stdscr, desc_y, box_x + 3, f" {desc}"[:box_w - 6], curses.color_pair(C_DESC)) + + if max_scroll > 0: + scroll_info = f" [{scroll + 1}-{min(scroll + max_visible, total_items)}/{total_items}] " + _safe_addstr(stdscr, desc_y, box_x + box_w - len(scroll_info) - 2, scroll_info, curses.color_pair(C_DESC)) + + hint_y = box_y + box_h + 1 + hint = "↑↓ 移动 Enter 编辑 S 保存 Esc 取消" + if max_scroll > 0: + hint = "↑↓/PgUp/PgDn 滚动 " + hint + _draw_hint(stdscr, hint_y, box_x + 2, hint) + + stdscr.refresh() + + ch = stdscr.getch() + if ch == curses.KEY_UP: + cursor = max(0, cursor - 1) + elif ch == curses.KEY_DOWN: + cursor = min(len(field_indices) - 1, cursor + 1) + elif ch == curses.KEY_PPAGE: + cursor = max(0, cursor - 5) + elif ch == curses.KEY_NPAGE: + cursor = min(len(field_indices) - 1, cursor + 5) + elif ch in (curses.KEY_ENTER, 10, 13): + field_data = items[field_indices[cursor]][1] + ftype = field_data.get("type", "text") + key = field_data["key"] + current = values.get(key, field_data["default"]) + + if ftype == "bool": + result = _step_bool(stdscr, field_data, current, cursor + 1, len(field_indices)) + if result is not None: + values[key] = result + elif ftype == "secret": + is_auto_flag = key in secret_auto + result, auto = _step_secret(stdscr, field_data, current, is_auto_flag, cursor + 1, len(field_indices)) + if result is not None: + values[key] = result + if auto: + secret_auto.add(key) + else: + secret_auto.discard(key) + elif ftype == "list": + result = _step_list(stdscr, field_data, current, cursor + 1, len(field_indices)) + if result is not None: + values[key] = result + else: + result = _step_text(stdscr, field_data, current, cursor + 1, len(field_indices)) + if result is not None: + values[key] = result + elif ch in (ord('s'), ord('S')): + return values + elif ch == 27: + return None + + +def _configure_env(stdscr, answers): + items = _get_env_items(answers) + field_items = [(i, data) for i, (kind, data) in enumerate(items) if kind == "field"] + total = len(field_items) + + existing = _load_existing_env(BASE_DIR / ".env") + values = {} + secret_auto = set() + + for idx, data in field_items: + key = data["key"] + if data.get("type") == "auto": + values[key] = data["default"] + elif key in existing: + values[key] = existing[key] + else: + values[key] = data["default"] + + step = 0 + while 0 <= step < total: + idx, data = field_items[step] + ftype = data.get("type", "text") + key = data["key"] + current = values.get(key, data["default"]) + + if ftype == "bool": + result = _step_bool(stdscr, data, current, step + 1, total) + if result is None: + step -= 1 + continue + values[key] = result + step += 1 + elif ftype == "secret": + is_auto_flag = key in secret_auto + result, auto = _step_secret(stdscr, data, current, is_auto_flag, step + 1, total) + if result is None: + step -= 1 + continue + values[key] = result + if auto: + secret_auto.add(key) + else: + secret_auto.discard(key) + step += 1 + elif ftype == "auto": + result = _step_auto(stdscr, data, current, step + 1, total) + if result is None: + step -= 1 + continue + step += 1 + elif ftype == "list": + result = _step_list(stdscr, data, current, step + 1, total) + if result is None: + step -= 1 + continue + values[key] = result + step += 1 + else: + result = _step_text(stdscr, data, current, step + 1, total) + if result is None: + step -= 1 + continue + values[key] = result + step += 1 + + if step < 0: + return None + + result = _env_preview(stdscr, items, values, secret_auto) + if result is None: + return None + + return result + + +def generate_env_content(answers, values): + lines = [] + lines.append("# ZASCA 环境配置文件") + lines.append("# 由 deploy.py 自动生成") + lines.append("") + + lines.append("# ========== 核心配置 ==========") + lines.append(f"DEBUG={values.get('DEBUG', 'True')}") + + secret_key = values.get('DJANGO_SECRET_KEY', '') + if not secret_key: + secret_key = _generate_secret(50) + lines.append(f"DJANGO_SECRET_KEY={secret_key}") + + lines.append(f"ALLOWED_HOSTS={values.get('ALLOWED_HOSTS', 'localhost,127.0.0.1')}") + lines.append(f"CSRF_TRUSTED_ORIGINS={values.get('CSRF_TRUSTED_ORIGINS', 'https://localhost,https://127.0.0.1')}") + lines.append("") + + db = answers.get("db", "sqlite") + lines.append("# ========== 数据库配置 ==========") + lines.append(f"DB_ENGINE={values.get('DB_ENGINE', db)}") + if db != "sqlite": + lines.append(f"DB_HOST={values.get('DB_HOST', '127.0.0.1')}") + lines.append(f"DB_PORT={values.get('DB_PORT', '3306')}") + lines.append(f"DB_NAME={values.get('DB_NAME', 'zasca')}") + lines.append(f"DB_USER={values.get('DB_USER', 'root')}") + db_pass = values.get('DB_PASSWORD', '') + if not db_pass: + db_pass = _generate_secret(50) + lines.append(f"DB_PASSWORD={db_pass}") + lines.append("") + + if answers.get("redis"): + lines.append("# ========== Redis 配置 ==========") + lines.append(f"REDIS_URL={values.get('REDIS_URL', 'redis://localhost:6379/0')}") + lines.append("") + + if answers.get("celery"): + lines.append("# ========== Celery 配置 ==========") + broker = values.get('CELERY_BROKER_URL', '') + backend = values.get('CELERY_RESULT_BACKEND', '') + if not broker and answers.get("redis"): + redis_url = values.get('REDIS_URL', 'redis://localhost:6379/0') + broker = redis_url.replace('/0', '/1') + if not backend and answers.get("redis"): + redis_url = values.get('REDIS_URL', 'redis://localhost:6379/0') + backend = redis_url.replace('/0', '/2') + if broker: + lines.append(f"CELERY_BROKER_URL={broker}") + if backend: + lines.append(f"CELERY_RESULT_BACKEND={backend}") + lines.append("") + + lines.append("# ========== 演示模式 ==========") + lines.append(f"ZASCA_DEMO={values.get('ZASCA_DEMO', '0')}") + lines.append("") + + lines.append("# ========== 安全配置 ==========") + lines.append(f"SECURE_SSL_REDIRECT={values.get('SECURE_SSL_REDIRECT', 'False')}") + lines.append(f"SESSION_COOKIE_SECURE={values.get('SESSION_COOKIE_SECURE', 'False')}") + lines.append(f"CSRF_COOKIE_SECURE={values.get('CSRF_COOKIE_SECURE', 'False')}") + lines.append("") + + lines.append("# ========== 日志配置 ==========") + lines.append(f"LOG_LEVEL={values.get('LOG_LEVEL', 'DEBUG')}") + lines.append(f"LOG_FILE={values.get('LOG_FILE', '/var/log/2c2a/application.log')}") + lines.append("") + + if answers.get("winrm"): + lines.append("# ========== WinRM 配置 ==========") + lines.append(f"WINRM_TIMEOUT={values.get('WINRM_TIMEOUT', '30')}") + lines.append(f"WINRM_RETRY_COUNT={values.get('WINRM_RETRY_COUNT', '3')}") + lines.append("") + + lines.append("# ========== Gateway 配置 ==========") + lines.append(f"GATEWAY_ENABLED={values.get('GATEWAY_ENABLED', 'False')}") + lines.append(f"GATEWAY_CONTROL_SOCKET={values.get('GATEWAY_CONTROL_SOCKET', '/run/2c2a/control.sock')}") + lines.append("") + + beta_fields = ['BETA_DB_NAME', 'BETA_DB_USER', 'BETA_DB_PASSWORD', 'BETA_DB_HOST', 'BETA_DB_PORT'] + has_beta = any(values.get(f, '') for f in beta_fields) + if has_beta: + lines.append("# ========== Beta 数据库配置 ==========") + for f in beta_fields: + if f == 'BETA_DB_PASSWORD': + bp = values.get(f, '') + if not bp: + bp = _generate_secret(50) + lines.append(f"{f}={bp}") + else: + lines.append(f"{f}={values.get(f, '')}") + lines.append("") + + lines.append("# ========== Bootstrap 认证配置 ==========") + salt = values.get('BOOTSTRAP_SHARED_SALT', '') + if not salt: + salt = _generate_secret(50) + lines.append(f"BOOTSTRAP_SHARED_SALT={salt}") + lines.append("") + + return "\n".join(lines) def compute_dependencies(answers): diff --git a/static/js/email_code.js b/static/js/email_code.js index 7494216..7a2ae34 100755 --- a/static/js/email_code.js +++ b/static/js/email_code.js @@ -1,35 +1,22 @@ -// email_code.js -// Handles "Get code" button on registration and forgot password pages. -// If CAPTCHA_PROVIDER == 'geetest', it will open geetest popup via initGeetest4 and then POST v4 params + email to /accounts/email/send-code/ or /accounts/email/send-forgot-password-code/ -// Otherwise, it will POST only the email to the endpoint. - -(function(){ - function qs(sel){ return document.querySelector(sel); } +(function () { + function qs(sel) { return document.querySelector(sel); } var btn = qs('#get-email-code'); - if(!btn) return; + if (!btn) return; - // 倒计时功能 var countdownTimers = {}; - - function startCountdown(button, initialText = null) { + + function startCountdown(button, initialText) { var buttonId = button.id || 'unknown-button'; - - // 清除之前的倒计时 if (countdownTimers[buttonId]) { clearInterval(countdownTimers[buttonId]); } - - let count = 60; - const originalText = initialText || button.textContent || button.innerText; + var count = 60; + var originalText = initialText || button.textContent || '获取验证码'; button.disabled = true; - - // 更新按钮文本显示倒计时 - button.textContent = `${originalText} (${count}s)`; - - countdownTimers[buttonId] = setInterval(() => { + button.textContent = originalText + ' (' + count + 's)'; + countdownTimers[buttonId] = setInterval(function () { count--; - button.textContent = `${originalText} (${count}s)`; - + button.textContent = originalText + ' (' + count + 's)'; if (count <= 0) { clearInterval(countdownTimers[buttonId]); delete countdownTimers[buttonId]; @@ -39,110 +26,51 @@ }, 1000); } - btn.addEventListener('click', function(e){ + btn.addEventListener('click', function (e) { e.preventDefault(); var emailInput = document.querySelector('input[name="email"]') || document.querySelector('input[type="email"]'); var email = emailInput && emailInput.value && emailInput.value.trim(); - if(!email){ alert('请先输入邮箱'); return; } + if (!email) { alert('请先输入邮箱'); return; } - // Check if we're on the forgot password page var isForgotPassword = window.location.pathname.includes('forgot-password'); var endpoint = isForgotPassword ? '/accounts/email/send-forgot-password-code/' : '/accounts/email/send-code/'; + var provider = window.CAPTCHA_PROVIDER || 'none'; - // CAPTCHA_PROVIDER is injected in template context as CAPTCHA_PROVIDER - var provider = window.CAPTCHA_PROVIDER || document.body.getAttribute('data-captcha-provider') || 'none'; + if (provider === 'tianai') { + return; + } - function postCode(payload, buttonRef){ + function postCode(payload, buttonRef) { fetch(endpoint, { method: 'POST', credentials: 'same-origin', headers: { - 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : (document.querySelector('[name=csrfmiddlewaretoken]')?.value || '') + 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]') ? document.querySelector('[name=csrfmiddlewaretoken]').value : '' }, body: payload - }).then(function(resp){ - if(resp.ok) { + }).then(function (resp) { + if (resp.ok) { alert('验证码已发送,请注意查收'); - // 开始倒计时 - if(buttonRef) { - startCountdown(buttonRef, '获取验证码'); - } + if (buttonRef) startCountdown(buttonRef, '获取验证码'); } else { - // 请求失败时恢复按钮状态 - if(buttonRef) { + if (buttonRef) { buttonRef.disabled = false; buttonRef.textContent = '获取验证码'; } - resp.json().then(function(j){ alert('发送失败:' + (j.message || JSON.stringify(j))); }).catch(function(){ alert('发送失败'); }); + resp.json().then(function (j) { alert('发送失败:' + (j.message || JSON.stringify(j))); }).catch(function () { alert('发送失败'); }); } - }).catch(function(err){ - console.error(err); - // 请求失败时恢复按钮状态 - if(buttonRef) { + }).catch(function (err) { + console.error(err); + if (buttonRef) { buttonRef.disabled = false; buttonRef.textContent = '获取验证码'; } - alert('网络错误'); + alert('网络错误'); }); } - if(provider === 'geetest'){ - var emailCaptchaId = btn.getAttribute('data-captcha-id') || window.GEETEST_CAPTCHA_ID; - if(!emailCaptchaId){ - alert('Geetest captcha ID 未配置'); - return; - } - initGeetest4({ - captchaId: emailCaptchaId, - product: 'bind' - }, function(captchaObj){ - captchaObj.onReady(function(){ - captchaObj.showCaptcha(); - }); - captchaObj.onSuccess(function(){ - var result = captchaObj.getValidate(); - if(!result){ - alert('验证结果获取失败,请重试'); - return; - } - var form = new FormData(); - form.append('email', email); - form.append('lot_number', result.lot_number || ''); - form.append('captcha_output', result.captcha_output || ''); - form.append('pass_token', result.pass_token || ''); - form.append('gen_time', result.gen_time || ''); - form.append('captcha_id', emailCaptchaId); - postCode(form, btn); - }); - captchaObj.onError(function(error){ - console.error('Geetest v4 error:', error); - alert('验证码加载失败,请稍后重试'); - }); - captchaObj.onClose(function(){ - }); - }); - } else if(provider === 'local') { - // 如果是本地验证码,不应该到达这里,因为local_captcha_adapter.js会处理 - // 但为了兼容性,我们也可以处理 - alert('本地验证码需要先完成验证'); - } else { - if(provider === 'turnstile'){ - var sitekey = window.TURNSTILE_SITE_KEY || (document.getElementById('turnstile_site_key') && document.getElementById('turnstile_site_key').value); - if(!sitekey){ alert('Turnstile site key 未配置'); return; } - // execute turnstile - if(typeof window.executeTurnstile === 'function'){ - window.executeTurnstile(sitekey, function(err, token){ - if(err){ alert('Turnstile 验证失败'); return; } - var fd = new FormData(); fd.append('email', email); fd.append('cf-turnstile-response', token); - postCode(fd, btn); // Pass button reference for countdown - }); - } else { - alert('Turnstile adapter 未加载'); - } - } else { - var fd = new FormData(); fd.append('email', email); - postCode(fd, btn); // Pass button reference for countdown - } - } + var fd = new FormData(); + fd.append('email', email); + postCode(fd, btn); }); -})(); \ No newline at end of file +})(); diff --git a/static/js/geetest_v4_adapter.js b/static/js/geetest_v4_adapter.js deleted file mode 100755 index a1308e2..0000000 --- a/static/js/geetest_v4_adapter.js +++ /dev/null @@ -1,92 +0,0 @@ -(function () { - function $all(sel) { return Array.prototype.slice.call(document.querySelectorAll(sel)); } - - var defaultCaptchaId = window.GEETEST_CAPTCHA_ID || null; - - function initOnTrigger() { - var triggers = $all('[data-geetest-trigger]:not([data-geetest-email-trigger])'); - triggers.forEach(function (btn) { - var form = btn.closest('form'); - var captchaId = btn.getAttribute('data-captcha-id') || defaultCaptchaId; - if (!captchaId) { - console.error('captchaId not provided for Geetest v4'); - return; - } - - var captchaInstance = null; - var ready = false; - - initGeetest4({ - captchaId: captchaId, - product: 'bind' - }, function (captchaObj) { - captchaInstance = captchaObj; - - captchaObj.onReady(function () { - ready = true; - }); - - captchaObj.onSuccess(function () { - var result = captchaObj.getValidate(); - if (!result) { - alert('验证结果获取失败,请重试'); - return; - } - - if (form) { - var inLot = form.querySelector('input[name="lot_number"]'); - var inOutput = form.querySelector('input[name="captcha_output"]'); - var inPass = form.querySelector('input[name="pass_token"]'); - var inGen = form.querySelector('input[name="gen_time"]'); - var inCid = form.querySelector('input[name="captcha_id"]'); - - if (inLot) inLot.value = result.lot_number || ''; - if (inOutput) inOutput.value = result.captcha_output || ''; - if (inPass) inPass.value = result.pass_token || ''; - if (inGen) inGen.value = result.gen_time || ''; - if (inCid) inCid.value = captchaId; - } - - if (form) form.submit(); - }); - - captchaObj.onError(function (error) { - console.error('Geetest v4 error:', error); - alert('验证码加载失败,请稍后重试'); - }); - }); - - btn.addEventListener('click', function (e) { - e.preventDefault(); - if (ready && captchaInstance) { - captchaInstance.showCaptcha(); - } - }); - - if (form) { - form.addEventListener('submit', function (e) { - var passTokenInput = form.querySelector('input[name="pass_token"]'); - var passTokenValue = passTokenInput ? passTokenInput.value : ''; - if (!passTokenValue && window.CAPTCHA_PROVIDER === 'geetest') { - e.preventDefault(); - if (ready && captchaInstance) { - captchaInstance.showCaptcha(); - } - } - }); - } - }); - } - - window.initGeetestAdapter = { - initOnTrigger: initOnTrigger - }; - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', function () { - initOnTrigger(); - }); - } else { - initOnTrigger(); - } -})(); diff --git a/static/js/gt4.js b/static/js/gt4.js deleted file mode 100755 index 5da8039..0000000 --- a/static/js/gt4.js +++ /dev/null @@ -1,487 +0,0 @@ -"v4.2.0 Geetest Inc."; - -(function (window) { - "use strict"; - if (typeof window === 'undefined') { - throw new Error('Geetest requires browser environment'); - } - -var document = window.document; -var Math = window.Math; -var head = document.getElementsByTagName("head")[0]; -var TIMEOUT = 10000; - -function _Object(obj) { - this._obj = obj; -} - -_Object.prototype = { - _each: function (process) { - var _obj = this._obj; - for (var k in _obj) { - if (_obj.hasOwnProperty(k)) { - process(k, _obj[k]); - } - } - return this; - }, - _extend: function (obj){ - var self = this; - new _Object(obj)._each(function (key, value){ - self._obj[key] = value; - }) - } -}; - -var uuid = function () { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { - var r = Math.random() * 16 | 0; - var v = c === 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); - }; - -function Config(config) { - var self = this; - new _Object(config)._each(function (key, value) { - self[key] = value; - }); -} - -Config.prototype = { - apiServers: ['gcaptcha4.geetest.com','gcaptcha4.geevisit.com','gcaptcha4.gsensebot.com'], - staticServers: ["static.geetest.com",'static.geevisit.com'], - protocol: 'http://', - typePath: '/load', - fallback_config: { - bypass: { - staticServers: ["static.geetest.com",'static.geevisit.com'], - type: 'bypass', - bypass: '/v4/bypass.js' - } - }, - _get_fallback_config: function () { - var self = this; - if (isString(self.type)) { - return self.fallback_config[self.type]; - } else { - return self.fallback_config.bypass; - } - }, - _extend: function (obj) { - var self = this; - new _Object(obj)._each(function (key, value) { - self[key] = value; - }) - } -}; -var isNumber = function (value) { - return (typeof value === 'number'); -}; -var isString = function (value) { - return (typeof value === 'string'); -}; -var isBoolean = function (value) { - return (typeof value === 'boolean'); -}; -var isObject = function (value) { - return (typeof value === 'object' && value !== null); -}; -var isFunction = function (value) { - return (typeof value === 'function'); -}; -var MOBILE = /Mobi/i.test(navigator.userAgent); - -var callbacks = {}; -var status = {}; - -var random = function () { - return parseInt(Math.random() * 10000) + (new Date()).valueOf(); -}; - -// bind 函数polify, 不带new功能的bind - -var bind = function(target,context){ - if(typeof target !== 'function'){ - return; - } - var args = Array.prototype.slice.call(arguments,2); - - if(Function.prototype.bind){ - return target.bind(context, args); - }else { - return function(){ - var _args = Array.prototype.slice.call(arguments); - return target.apply(context,args.concat(_args)); - } - } -} - - - -var toString = Object.prototype.toString; - -var _isFunction = function(obj) { - return typeof(obj) === 'function'; -}; -var _isObject = function(obj) { - return obj === Object(obj); -}; -var _isArray = function(obj) { - return toString.call(obj) == '[object Array]'; -}; -var _isDate = function(obj) { - return toString.call(obj) == '[object Date]'; -}; -var _isRegExp = function(obj) { - return toString.call(obj) == '[object RegExp]'; -}; -var _isBoolean = function(obj) { - return toString.call(obj) == '[object Boolean]'; -}; - - -function resolveKey(input){ - return input.replace(/(\S)(_([a-zA-Z]))/g, function(match, $1, $2, $3){ - return $1 + $3.toUpperCase() || ""; - }) -} - -function camelizeKeys(input, convert){ - if(!_isObject(input) || _isDate(input) || _isRegExp(input) || _isBoolean(input) || _isFunction(input)){ - return convert ? resolveKey(input) : input; - } - - if(_isArray(input)){ - var temp = []; - for(var i = 0; i < input.length; i++){ - temp.push(camelizeKeys(input[i])); - } - - }else { - var temp = {}; - for(var prop in input){ - if(input.hasOwnProperty(prop)){ - temp[camelizeKeys(prop, true)] = camelizeKeys(input[prop]); - } - } - } - return temp; -} - -var loadScript = function (url, cb, timeout) { - var script = document.createElement("script"); - script.charset = "UTF-8"; - script.async = true; - - // 对geetest的静态资源添加 crossOrigin - if ( /static\.geetest\.com/g.test(url)) { - script.crossOrigin = "anonymous"; - } - - script.onerror = function () { - cb(true); - // 错误触发了,超时逻辑就不用了 - loaded = true; - }; - var loaded = false; - script.onload = script.onreadystatechange = function () { - if (!loaded && - (!script.readyState || - "loaded" === script.readyState || - "complete" === script.readyState)) { - - loaded = true; - setTimeout(function () { - cb(false); - }, 0); - } - }; - script.src = url; - head.appendChild(script); - - setTimeout(function () { - if (!loaded) { - script.onerror = script.onload = null; - script.remove && script.remove(); - cb(true); - } - }, timeout || TIMEOUT); -}; - -var normalizeDomain = function (domain) { - // special domain: uems.sysu.edu.cn/jwxt/geetest/ - // return domain.replace(/^https?:\/\/|\/.*$/g, ''); uems.sysu.edu.cn - return domain.replace(/^https?:\/\/|\/$/g, ''); // uems.sysu.edu.cn/jwxt/geetest -}; -var normalizePath = function (path) { - - path = path && path.replace(/\/+/g, '/'); - if (path.indexOf('/') !== 0) { - path = '/' + path; - } - return path; -}; -var normalizeQuery = function (query) { - if (!query) { - return ''; - } - var q = '?'; - new _Object(query)._each(function (key, value) { - if (isString(value) || isNumber(value) || isBoolean(value)) { - q = q + encodeURIComponent(key) + '=' + encodeURIComponent(value) + '&'; - } - }); - if (q === '?') { - q = ''; - } - return q.replace(/&$/, ''); -}; -var makeURL = function (protocol, domain, path, query) { - domain = normalizeDomain(domain); - - var url = normalizePath(path) + normalizeQuery(query); - if (domain) { - url = protocol + domain + url; - } - - return url; -}; - -var load = function (config, protocol, domains, path, query, cb, handleCb) { - var tryRequest = function (at) { - // 处理jsonp回调,这里为了保证每个不同jsonp都有唯一的回调函数 - if(handleCb){ - var cbName = "geetest_" + random(); - // 需要与预先定义好cbname参数,删除对象 - window[cbName] = bind(handleCb, null, cbName); - query.callback = cbName; - } - var url = makeURL(protocol, domains[at], path, query); - loadScript(url, function (err) { - if (err) { - // 超时或者出错的时候 移除回调 - if(cbName){ - try { - window[cbName] = function(){ - window[cbName] = null; - } - } catch (e) {} - } - - if (at >= domains.length - 1) { - cb(true); - // report gettype error - } else { - tryRequest(at + 1); - } - } else { - cb(false); - } - }, config.timeout); - }; - tryRequest(0); -}; - - -var jsonp = function (domains, path, config, callback) { - - var handleCb = function (cbName, data) { - - // 保证只执行一次,全部超时的情况下不会再触发; - - if (data.status == 'success') { - callback(data.data); - } else if (!data.status) { - callback(data); - } else { - //接口有返回,但是返回了错误状态,进入报错逻辑 - callback(data); - } - window[cbName] = undefined; - try { - delete window[cbName]; - } catch (e) { - } - }; - load(config, config.protocol, domains, path, { - callback: '', - captcha_id: config.captchaId, - challenge: config.challenge || uuid(), - client_type: config.clientType ? config.clientType : (MOBILE? 'h5':'web'), - risk_type: config.riskType, - user_info: config.userInfo, - call_type: config.callType, - lang: config.language? config.language : navigator.appName === 'Netscape' ? navigator.language.toLowerCase() : navigator.userLanguage.toLowerCase() - }, function (err) { - // 网络问题接口没有返回,直接使用本地验证码,走宕机模式 - // 这里可以添加用户的逻辑 - if(err && typeof config.offlineCb === 'function'){ - // 执行自己的宕机 - config.offlineCb(); - return; - } - if(err){ - callback(config._get_fallback_config()); - } - }, handleCb); -}; - -var reportError = function (config, url) { - load(config, config.protocol, ['monitor.geetest.com'], '/monitor/send', { - time: Date.now().getTime(), - captcha_id: config.gt, - challenge: config.challenge, - exception_url: url, - error_code: config.error_code - }, function (err) {}) -} - -var throwError = function (errorType, config, errObj) { - var errors = { - networkError: '网络错误', - gtTypeError: 'gt字段不是字符串类型' - }; - if (typeof config.onError === 'function') { - config.onError({ - desc: errObj.desc, - msg: errObj.msg, - code: errObj.code - }); - } else { - throw new Error(errors[errorType]); - } -}; - -var detect = function () { - return window.Geetest || document.getElementById("gt_lib"); -}; - -if (detect()) { - status.slide = "loaded"; -} -var GeetestIsLoad = function (fname) { - var GeetestIsLoad = false; - var tags = { js: 'script', css: 'link' }; - var tagname = fname && tags[fname.split('.').pop()]; - if (tagname !== undefined) { - var elts = document.getElementsByTagName(tagname); - for (var i in elts) { - if ((elts[i].href && elts[i].href.toString().indexOf(fname) > 0) - || (elts[i].src && elts[i].src.toString().indexOf(fname) > 0)) { - GeetestIsLoad = true; - } - } - } - return GeetestIsLoad; -}; -window.initGeetest4 = function (userConfig,callback) { - - var config = new Config(userConfig); - if (userConfig.https) { - config.protocol = 'https://'; - } else if (!userConfig.protocol) { - config.protocol = window.location.protocol + '//'; - } - - - if (isObject(userConfig.getType)) { - config._extend(userConfig.getType); - } - - jsonp(config.apiServers , config.typePath, config, function (newConfig) { - //错误捕获,第一个load请求可能直接报错 - var newConfig = camelizeKeys(newConfig); - - if(newConfig.status === 'error'){ - return throwError('networkError', config, newConfig); - } - - var type = newConfig.type; - if(config.debug){ - new _Object(newConfig)._extend(config.debug) - } - var init = function () { - config._extend(newConfig); - callback(new window.Geetest4(config)); - }; - - callbacks[type] = callbacks[type] || []; - - var s = status[type] || 'init'; - if (s === 'init') { - status[type] = 'loading'; - - callbacks[type].push(init); - - if(newConfig.gctPath){ - load(config, config.protocol, Object.hasOwnProperty.call(config, 'staticServers') ? config.staticServers : newConfig.staticServers || config.staticServers , newConfig.gctPath, null, function (err){ - if(err){ - throwError('networkError', config, { - code: '60205', - msg: 'Network failure', - desc: { - detail: 'gct resource load timeout' - } - }); - } - }) - } - - load(config, config.protocol, Object.hasOwnProperty.call(config, 'staticServers') ? config.staticServers : newConfig.staticServers || config.staticServers, newConfig.bypass || (newConfig.staticPath + newConfig.js), null, function (err) { - if (err) { - status[type] = 'fail'; - throwError('networkError', config, { - code: '60204', - msg: 'Network failure', - desc: { - detail: 'js resource load timeout' - } - }); - } else { - - status[type] = 'loaded'; - var cbs = callbacks[type]; - for (var i = 0, len = cbs.length; i < len; i = i + 1) { - var cb = cbs[i]; - if (isFunction(cb)) { - cb(); - } - } - callbacks[type] = []; - status[type] = 'init'; - } - }); - } else if (s === "loaded") { - // 判断gct是否需要重新加载 - if(newConfig.gctPath && !GeetestIsLoad(newConfig.gctPath)){ - load(config, config.protocol, Object.hasOwnProperty.call(config, 'staticServers') ? config.staticServers : newConfig.staticServers || config.staticServers , newConfig.gctPath, null, function (err){ - if(err){ - throwError('networkError', config, { - code: '60205', - msg: 'Network failure', - desc: { - detail: 'gct resource load timeout' - } - }); - } - }) - } - return init(); - } else if (s === "fail") { - throwError('networkError', config, { - code: '60204', - msg: 'Network failure', - desc: { - detail: 'js resource load timeout' - } - }); - } else if (s === "loading") { - callbacks[type].push(init); - } - }); - -}; - - -})(window); diff --git a/static/js/local_captcha_adapter.js b/static/js/local_captcha_adapter.js deleted file mode 100755 index 5a78383..0000000 --- a/static/js/local_captcha_adapter.js +++ /dev/null @@ -1,340 +0,0 @@ -class LocalCaptchaAdapter { - constructor(options = {}) { - this.options = { - product: options.product || 'popup', - ...options - }; - this.captchaId = null; - this.modal = null; - this.resolveCallback = null; - } - - async showBox() { - return new Promise((resolve) => { - this.resolveCallback = resolve; - this._createModal(); - }); - } - - _createModal() { - const existing = document.getElementById('local-captcha-overlay'); - if (existing) existing.remove(); - - const overlay = document.createElement('div'); - overlay.id = 'local-captcha-overlay'; - overlay.className = 'fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-md'; - - const modal = document.createElement('div'); - modal.id = 'local-captcha-modal'; - modal.className = 'bg-black/90 backdrop-blur-2xl border border-white/10 rounded-md-lg shadow-2xl p-6 w-[320px] max-w-[90vw] relative'; - - const title = document.createElement('h3'); - title.className = 'text-lg font-medium text-white mb-4 flex items-center gap-2'; - title.innerHTML = 'verified_user请输入验证码'; - - const imgRow = document.createElement('div'); - imgRow.className = 'flex items-center gap-3 mb-4'; - - const captchaImg = document.createElement('img'); - captchaImg.id = 'captcha-image'; - captchaImg.className = 'h-10 w-[120px] border border-slate-700/50 rounded-md cursor-pointer hover:opacity-80 transition'; - captchaImg.alt = '验证码'; - captchaImg.title = '点击刷新验证码'; - captchaImg.addEventListener('click', () => this._refreshCaptcha()); - - const refreshBtn = document.createElement('button'); - refreshBtn.type = 'button'; - refreshBtn.className = 'flex items-center justify-center w-9 h-9 rounded-full bg-slate-800/50 border border-slate-700/50 text-slate-400 hover:text-cyan-400 transition cursor-pointer'; - refreshBtn.title = '刷新验证码'; - refreshBtn.innerHTML = 'refresh'; - refreshBtn.addEventListener('click', () => this._refreshCaptcha()); - - imgRow.appendChild(captchaImg); - imgRow.appendChild(refreshBtn); - - const input = document.createElement('input'); - input.type = 'text'; - input.id = 'captcha-input'; - input.placeholder = '请输入验证码'; - input.maxLength = 4; - input.autocomplete = 'off'; - input.className = 'w-full bg-slate-900/50 border border-slate-700/50 rounded-md px-4 py-3 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition text-center text-lg tracking-[0.3em] uppercase'; - - const errorMsg = document.createElement('p'); - errorMsg.id = 'captcha-error'; - errorMsg.className = 'text-sm text-red-400 mt-1 hidden'; - - const btnRow = document.createElement('div'); - btnRow.className = 'flex gap-3 mt-4'; - - const cancelBtn = document.createElement('button'); - cancelBtn.type = 'button'; - cancelBtn.className = 'flex-1 bg-slate-800/50 border border-slate-700/50 rounded-md px-4 py-2.5 text-slate-300 hover:bg-slate-700/50 transition font-medium cursor-pointer'; - cancelBtn.textContent = '取消'; - cancelBtn.addEventListener('click', () => { - this._closeModal(); - if (this.resolveCallback) { - this.resolveCallback({ status: 'closed' }); - } - }); - - const confirmBtn = document.createElement('button'); - confirmBtn.type = 'button'; - confirmBtn.className = 'flex-1 bg-cyan-600 hover:bg-cyan-500 text-white rounded-md px-4 py-2.5 shadow-[0_0_15px_-3px_rgba(34,211,238,0.3)] transition font-medium cursor-pointer'; - confirmBtn.textContent = '确认'; - confirmBtn.addEventListener('click', () => this._verifyCaptcha()); - - btnRow.appendChild(cancelBtn); - btnRow.appendChild(confirmBtn); - - modal.appendChild(title); - modal.appendChild(imgRow); - modal.appendChild(input); - modal.appendChild(errorMsg); - modal.appendChild(btnRow); - overlay.appendChild(modal); - - overlay.addEventListener('click', (e) => { - if (e.target === overlay) { - this._closeModal(); - if (this.resolveCallback) { - this.resolveCallback({ status: 'closed' }); - } - } - }); - - input.addEventListener('keypress', (e) => { - if (e.key === 'Enter') this._verifyCaptcha(); - }); - - document.body.appendChild(overlay); - - this._generateCaptcha(); - - setTimeout(() => input.focus(), 100); - } - - async _generateCaptcha() { - try { - const resp = await fetch('/accounts/captcha/generate/'); - const data = await resp.json(); - if (data.captcha_id) { - this.captchaId = data.captcha_id; - const img = document.getElementById('captcha-image'); - if (img) { - img.src = `/accounts/captcha/image/${this.captchaId}/?t=${Date.now()}`; - } - } - } catch (err) { - console.error('生成验证码失败:', err); - this._showError('验证码生成失败,请稍后重试'); - } - } - - async _refreshCaptcha() { - const input = document.getElementById('captcha-input'); - if (input) input.value = ''; - this._hideError(); - await this._generateCaptcha(); - } - - async _verifyCaptcha() { - const input = document.getElementById('captcha-input'); - const userInput = input ? input.value.trim() : ''; - - if (!userInput) { - this._showError('请输入验证码'); - return; - } - - if (!this.captchaId) { - this._showError('验证码已过期,请重新获取'); - await this._generateCaptcha(); - return; - } - - this._closeModal(); - - const result = { - status: 'success', - lot_number: this.captchaId, - captcha_output: userInput, - pass_token: 'local_captcha_pass_token', - gen_time: Date.now().toString(), - captcha_id: this.captchaId - }; - - window._localCaptchaResult = result; - - if (this.resolveCallback) { - this.resolveCallback(result); - } - } - - _showError(msg) { - const el = document.getElementById('captcha-error'); - if (el) { - el.textContent = msg; - el.classList.remove('hidden'); - } - } - - _hideError() { - const el = document.getElementById('captcha-error'); - if (el) el.classList.add('hidden'); - } - - _closeModal() { - const overlay = document.getElementById('local-captcha-overlay'); - if (overlay) overlay.remove(); - this.modal = null; - } - - getCsrfToken() { - return window.getCsrfToken ? window.getCsrfToken() : - (document.querySelector('[name=csrfmiddlewaretoken]')?.value || - document.cookie.split('; ').find(row => row.startsWith('csrftoken='))?.split('=')[1] || ''); - } - - static initGeetest(config, callback) { - const adapter = new LocalCaptchaAdapter(config); - callback(adapter); - } -} - -window.initGeetest4 = LocalCaptchaAdapter.initGeetest; - -function _fillHiddenFields(result) { - const suffixes = ['login', 'reg', 'forgot']; - suffixes.forEach(suffix => { - const lotEl = document.getElementById(`lot_number_${suffix}`); - const outEl = document.getElementById(`captcha_output_${suffix}`); - const passEl = document.getElementById(`pass_token_${suffix}`); - const genEl = document.getElementById(`gen_time_${suffix}`); - if (lotEl) lotEl.value = result.lot_number; - if (outEl) outEl.value = result.captcha_output; - if (passEl) passEl.value = result.pass_token; - if (genEl) genEl.value = result.gen_time; - }); -} - -function _sendEmailCodeRequest(button) { - const emailField = document.querySelector('input[type="email"]'); - if (!emailField || !emailField.value) { - alert('请先填写邮箱地址'); - return; - } - - let endpoint; - if (window.location.pathname.includes('/register/')) { - endpoint = '/accounts/email/send-code/'; - } else if (window.location.pathname.includes('/forgot-password/')) { - endpoint = '/accounts/email/send-forgot-password-code/'; - } else { - alert('无法确定当前页面类型'); - return; - } - - const result = window._localCaptchaResult; - const formData = new FormData(); - formData.append('email', emailField.value); - if (result) { - if (result.lot_number) formData.append('lot_number', result.lot_number); - if (result.captcha_output) formData.append('captcha_output', result.captcha_output); - if (result.pass_token) formData.append('pass_token', result.pass_token); - if (result.gen_time) formData.append('gen_time', result.gen_time); - } - - fetch(endpoint, { - method: 'POST', - body: formData, - headers: { - 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : (document.querySelector('[name=csrfmiddlewaretoken]')?.value || '') - } - }) - .then(response => response.json()) - .then(data => { - if (data.status === 'ok') { - alert('验证码已发送到您的邮箱'); - _startCountdown(button, '获取验证码'); - } else { - alert(data.message || '发送验证码失败'); - } - }) - .catch(error => { - console.error('Error:', error); - alert('发送验证码时发生错误'); - }); -} - -let _countdownTimer = null; - -function _startCountdown(button, initialText) { - if (_countdownTimer) clearInterval(_countdownTimer); - - let count = 60; - const originalText = initialText || '获取验证码'; - button.disabled = true; - button.textContent = `${originalText} (${count}s)`; - - _countdownTimer = setInterval(() => { - count--; - button.textContent = `${originalText} (${count}s)`; - if (count <= 0) { - clearInterval(_countdownTimer); - _countdownTimer = null; - button.disabled = false; - button.textContent = originalText; - } - }, 1000); -} - -document.addEventListener('DOMContentLoaded', function () { - if (window.CAPTCHA_PROVIDER !== 'local') return; - - document.querySelectorAll('#get-email-code[data-local-captcha-trigger]').forEach(button => { - const newBtn = button.cloneNode(true); - button.parentNode.replaceChild(newBtn, button); - - newBtn.addEventListener('click', async function (e) { - e.preventDefault(); - e.stopImmediatePropagation(); - if (this.disabled) return; - - const emailField = document.querySelector('input[type="email"]'); - if (!emailField || !emailField.value) { - alert('请先填写邮箱地址'); - emailField?.focus(); - return; - } - - const adapter = new LocalCaptchaAdapter({ product: 'popup' }); - const result = await adapter.showBox(); - - if (result && result.status === 'success') { - _fillHiddenFields(result); - _sendEmailCodeRequest(this); - } - }); - }); - - document.querySelectorAll('[data-local-captcha-trigger]:not(#get-email-code)').forEach(button => { - button.addEventListener('click', async function (e) { - e.preventDefault(); - e.stopImmediatePropagation(); - - const adapter = new LocalCaptchaAdapter({ product: 'popup' }); - const result = await adapter.showBox(); - - if (result && result.status === 'success') { - _fillHiddenFields(result); - - const action = this.dataset.action; - if (action === 'submit') { - let form = this.closest('form'); - if (form) form.submit(); - } - } - }); - }); -}); diff --git a/static/js/tianai_adapter.js b/static/js/tianai_adapter.js new file mode 100644 index 0000000..e10b2db --- /dev/null +++ b/static/js/tianai_adapter.js @@ -0,0 +1,211 @@ +(function () { + function qs(sel) { return document.querySelector(sel); } + function $all(sel) { return Array.prototype.slice.call(document.querySelectorAll(sel)); } + + var _countdownTimer = null; + var _activeOverlay = null; + var _activeCaptchaBox = null; + + function startCountdown(button, initialText) { + if (_countdownTimer) clearInterval(_countdownTimer); + var count = 60; + var originalText = initialText || '获取验证码'; + button.disabled = true; + button.textContent = originalText + ' (' + count + 's)'; + _countdownTimer = setInterval(function () { + count--; + button.textContent = originalText + ' (' + count + 's)'; + if (count <= 0) { + clearInterval(_countdownTimer); + _countdownTimer = null; + button.disabled = false; + button.textContent = originalText; + } + }, 1000); + } + + function getGenerateUrl(captchaType) { + var baseUrl = '/captcha/generate'; + if (captchaType && captchaType !== 'SLIDER') { + return baseUrl + '?type=' + encodeURIComponent(captchaType); + } + return baseUrl; + } + + function createModal() { + if (_activeOverlay) return _activeOverlay.querySelector('#tianai-captcha-box'); + + var overlay = document.createElement('div'); + overlay.id = 'tianai-captcha-overlay'; + overlay.style.cssText = 'position:fixed;inset:0;z-index:9998;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;'; + + var captchaBox = document.createElement('div'); + captchaBox.id = 'tianai-captcha-box'; + captchaBox.style.cssText = 'position:relative;'; + + overlay.appendChild(captchaBox); + document.body.appendChild(overlay); + + _activeOverlay = overlay; + _activeCaptchaBox = captchaBox; + + overlay.addEventListener('click', function (e) { + if (e.target === overlay) { + destroyModal(); + } + }); + + return captchaBox; + } + + function destroyModal() { + if (_activeOverlay) { + _activeOverlay.remove(); + _activeOverlay = null; + _activeCaptchaBox = null; + } + } + + function showTianaiCaptcha(onSuccess, captchaType) { + var captchaBox = createModal(); + + var config = { + requestCaptchaDataUrl: getGenerateUrl(captchaType), + validCaptchaUrl: "/captcha/check", + bindEl: "#tianai-captcha-box", + validSuccess: function (res, c, tac) { + var token = null; + if (res && res.data && res.data.token) { + token = res.data.token; + } else if (res && res.token) { + token = res.token; + } + tac.destroyWindow(); + destroyModal(); + if (onSuccess) onSuccess(token); + }, + validFail: function (res, c, tac) { + tac.reloadCaptcha(); + }, + btnCloseFun: function (el, tac) { + tac.destroyWindow(); + destroyModal(); + } + }; + var style = {}; + var tac = new window.TAC(config, style); + tac.init(); + } + + function setTokenField(form, token) { + var input = form.querySelector('input[name="captcha_token"]'); + if (!input) { + input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'captcha_token'; + form.appendChild(input); + } + input.value = token || ''; + } + + function postEmailCode(email, token, button) { + var isForgotPassword = window.location.pathname.includes('forgot-password'); + var endpoint = isForgotPassword ? '/accounts/email/send-forgot-password-code/' : '/accounts/email/send-code/'; + + var formData = new FormData(); + formData.append('email', email); + if (token) { + formData.append('captcha_token', token); + } + + fetch(endpoint, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]') ? document.querySelector('[name=csrfmiddlewaretoken]').value : '' + }, + body: formData + }).then(function (resp) { + if (resp.ok) { + alert('验证码已发送,请注意查收'); + if (button) startCountdown(button, '获取验证码'); + } else { + if (button) { + button.disabled = false; + button.textContent = '获取验证码'; + } + resp.json().then(function (j) { alert('发送失败:' + (j.message || JSON.stringify(j))); }).catch(function () { alert('发送失败'); }); + } + }).catch(function (err) { + console.error(err); + if (button) { + button.disabled = false; + button.textContent = '获取验证码'; + } + alert('网络错误'); + }); + } + + document.addEventListener('DOMContentLoaded', function () { + if (window.CAPTCHA_PROVIDER !== 'tianai') return; + + var captchaType = window.CAPTCHA_TYPE || 'SLIDER'; + + $all('[data-tianai-captcha-trigger]').forEach(function (btn) { + btn.addEventListener('click', function (e) { + e.preventDefault(); + e.stopImmediatePropagation(); + + var form = btn.closest('form'); + showTianaiCaptcha(function (token) { + if (form) setTokenField(form, token); + var action = btn.dataset.action; + if (action === 'submit') { + form.submit(); + } + }, captchaType); + }); + }); + + $all('#get-email-code[data-tianai-email-trigger]').forEach(function (button) { + var newBtn = button.cloneNode(true); + button.parentNode.replaceChild(newBtn, button); + + newBtn.addEventListener('click', function (e) { + e.preventDefault(); + e.stopImmediatePropagation(); + if (this.disabled) return; + + var emailField = document.querySelector('input[type="email"]'); + if (!emailField || !emailField.value) { + alert('请先输入邮箱'); + emailField && emailField.focus(); + return; + } + + var emailCaptchaType = window.CAPTCHA_TYPE_EMAIL || captchaType; + showTianaiCaptcha(function (token) { + postEmailCode(emailField.value, token, newBtn); + }, emailCaptchaType); + }); + }); + + $all('form').forEach(function (form) { + var hasCaptchaTrigger = form.querySelector('[data-tianai-captcha-trigger]'); + if (!hasCaptchaTrigger) return; + + form.addEventListener('submit', function (e) { + var tokenField = form.querySelector('input[name="captcha_token"]'); + if (!tokenField || !tokenField.value) { + if (window.CAPTCHA_PROVIDER === 'tianai') { + e.preventDefault(); + showTianaiCaptcha(function (token) { + setTokenField(form, token); + form.submit(); + }, captchaType); + } + } + }); + }); + }); +})(); diff --git a/static/js/turnstile_adapter.js b/static/js/turnstile_adapter.js deleted file mode 100755 index 8fef2a3..0000000 --- a/static/js/turnstile_adapter.js +++ /dev/null @@ -1,70 +0,0 @@ -// turnstile_adapter.js -// Minimal adapter to render Cloudflare Turnstile widgets programmatically. -// Usage: -// - For login button, add data-turnstile-trigger to the button; adapter will render an invisible widget and submit the form when token is obtained. -// - For custom flows (like email code), call window.executeTurnstile(callback) to run an invisible widget and receive token. - -(function(){ - function loadScript(src, cb){ - if(window.turnstile) return cb(); - var s = document.createElement('script'); - s.src = src; - s.async = true; - s.onload = cb; - s.onerror = function(){ console.error('Failed to load turnstile script'); }; - document.head.appendChild(s); - } - - function ensureContainer(){ - var id = 'turnstile-popup-container'; - var c = document.getElementById(id); - if(!c){ c = document.createElement('div'); c.id = id; c.style.display='none'; document.body.appendChild(c); } - return c; - } - - function renderInvisible(sitekey, callback){ - loadScript('https://challenges.cloudflare.com/turnstile/v0/api.js', function(){ - var container = ensureContainer(); - container.innerHTML = ''; - // render invisible widget - var widgetId = window.turnstile.render(container, { - sitekey: sitekey, - size: 'invisible', - callback: function(token){ - callback(null, token); - }, - 'error-callback': function(){ callback(new Error('turnstile error')); }, - 'expired-callback': function(){ callback(new Error('turnstile expired')); } - }); - // execute - try{ window.turnstile.execute(widgetId); }catch(e){ console.error(e); } - }); - } - - window.executeTurnstile = function(sitekey, cb){ - if(!sitekey){ return cb(new Error('missing sitekey')); } - renderInvisible(sitekey, cb); - }; - - // attach to buttons with data-turnstile-trigger - document.addEventListener('DOMContentLoaded', function(){ - var btns = document.querySelectorAll('[data-turnstile-trigger]'); - Array.prototype.forEach.call(btns, function(btn){ - btn.addEventListener('click', function(e){ - e.preventDefault(); - var form = btn.closest('form'); - var sitekey = window.TURNSTILE_SITE_KEY || btn.getAttribute('data-turnstile-sitekey'); - if(!sitekey){ alert('Turnstile site key 未配置'); return; } - executeTurnstile(sitekey, function(err, token){ - if(err){ alert('Turnstile 验证失败'); return; } - // put token into hidden input - var input = form.querySelector('input[name="cf-turnstile-response"]'); - if(!input){ input = document.createElement('input'); input.type='hidden'; input.name='cf-turnstile-response'; form.appendChild(input); } - input.value = token; - // submit form - form.submit(); - }); - }); - }); - }); -})(); diff --git a/static/scripts/init.ps1 b/static/scripts/init.ps1 new file mode 100644 index 0000000..89319e9 --- /dev/null +++ b/static/scripts/init.ps1 @@ -0,0 +1,222 @@ +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$EncodedToken = $args[0] +if (-not $EncodedToken) { + Write-Host "Usage: & ([ScriptBlock]::Create(`$script)) [debug]" -ForegroundColor Red + return +} +$Debug = $args[1] -eq 'debug' -or $args[1] -eq '1' + +$padLen = 4 - ($EncodedToken.Length % 4) +if ($padLen -ne 4) { $EncodedToken += '=' * $padLen } +try { + $jsonBytes = [System.Convert]::FromBase64String($EncodedToken) + $jsonStr = [System.Text.Encoding]::UTF8.GetString($jsonBytes) + $tokenObj = $jsonStr | ConvertFrom-Json + $Token = $tokenObj.t + $Scheme = $tokenObj.s + $ServerHost = $tokenObj.h +} catch { + Write-Host "Token解码失败" -ForegroundColor Red; return +} +if (-not $Token -or -not $Scheme -or -not $ServerHost) { + Write-Host "Token格式无效" -ForegroundColor Red; return +} +$ServerUrl = "${Scheme}://${ServerHost}" +if ($Debug) { Write-Host " ServerUrl: $ServerUrl" -ForegroundColor DarkGray } + +Write-Host "=== 2c2a WinRM 证书自动配置 ===" -ForegroundColor Cyan + +Write-Host "[1/17] 验证Token..." -ForegroundColor Yellow +$validateResp = Invoke-RestMethod -Uri "$ServerUrl/bootstrap/api/cert-provision/validate/?token=$Token" -Method Get +if (-not $validateResp.valid) { + Write-Host "Token无效或已过期" -ForegroundColor Red; return +} +if ($Debug) { Write-Host " ServerHost: $ServerHost" -ForegroundColor DarkGray } +Write-Host " Token验证通过" -ForegroundColor Green + +Write-Host "[2/17] 上传主机名..." -ForegroundColor Yellow +$hostname = $env:COMPUTERNAME +$body = @{ token = $Token; hostname = $hostname } | ConvertTo-Json +Invoke-RestMethod -Uri "$ServerUrl/bootstrap/api/cert-provision/upload-hostname/" -Method Post -Body $body -ContentType "application/json" +if ($Debug) { Write-Host " 主机名: $hostname" -ForegroundColor DarkGray } +Write-Host " 主机名已上传: $hostname" -ForegroundColor Green + +Write-Host "[3/17] 等待证书签发..." -ForegroundColor Yellow +$certData = $null +$maxWait = 120 +$waited = 0 +while ($waited -lt $maxWait) { + Start-Sleep -Seconds 5 + $waited += 5 + try { + $statusResp = Invoke-RestMethod -Uri "$ServerUrl/bootstrap/api/cert-provision/validate/?token=$Token" -Method Get + if ($Debug) { Write-Host " Token状态: $($statusResp.status)" -ForegroundColor DarkGray } + } catch { + continue + } + try { + $certResp = Invoke-RestMethod -Uri "$ServerUrl/bootstrap/api/cert-provision/download-certs/?token=$Token" -Method Get + if ($certResp.ca_cert) { + $certData = $certResp + break + } + } catch { + } + Write-Host " 等待中... ($waited/${maxWait}s)" -ForegroundColor DarkGray +} +if (-not $certData) { + Write-Host "证书签发超时" -ForegroundColor Red; return +} +if ($Debug) { Write-Host " 证书数据已获取" -ForegroundColor DarkGray } +Write-Host " 证书已就绪" -ForegroundColor Green + +Write-Host "[4/17] 下载证书文件..." -ForegroundColor Yellow +$TempDir = "$env:TEMP\2c2a_Certs" +New-Item -ItemType Directory -Force -Path $TempDir | Out-Null +[System.IO.File]::WriteAllBytes("$TempDir\ca.crt", [System.Convert]::FromBase64String($certData.ca_cert)) +[System.IO.File]::WriteAllBytes("$TempDir\client.crt", [System.Convert]::FromBase64String($certData.client_cert)) +[System.IO.File]::WriteAllBytes("$TempDir\server.pfx", [System.Convert]::FromBase64String($certData.server_pfx)) +$PfxPassword = $certData.pfx_password +$NtlmUser = $certData.ntlm_user +$NtlmPassword = $certData.ntlm_password +$UpnValue = $certData.upn_value +if ($Debug) { Write-Host " 保存路径: $TempDir" -ForegroundColor DarkGray } +Write-Host " 证书文件已保存到 $TempDir" -ForegroundColor Green + +Write-Host "[5/17] 导入证书..." -ForegroundColor Yellow +$tempCa = Import-Certificate -FilePath "$TempDir\ca.crt" -CertStoreLocation Cert:\LocalMachine\Root +$caIssuerPattern = $tempCa.Subject -replace '.*CN=([^,]+).*','$1' +Get-ChildItem Cert:\LocalMachine\Root | Where-Object Subject -match $caIssuerPattern | Remove-Item -Force +$importedCa = Import-Certificate -FilePath "$TempDir\ca.crt" -CertStoreLocation Cert:\LocalMachine\Root +Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.Issuer -match $caIssuerPattern } | Remove-Item -Force +Get-ChildItem Cert:\LocalMachine\TrustedPeople | Where-Object Subject -match "winrm-client" | Remove-Item -Force +$secPwd = ConvertTo-SecureString $PfxPassword -AsPlainText -Force +$importedServer = Import-PfxCertificate -FilePath "$TempDir\server.pfx" -CertStoreLocation Cert:\LocalMachine\My -Password $secPwd +Import-Certificate -FilePath "$TempDir\client.crt" -CertStoreLocation Cert:\LocalMachine\TrustedPeople +if ($Debug) { Write-Host " CA Thumbprint: $($importedCa.Thumbprint)" -ForegroundColor DarkGray } +if ($Debug) { Write-Host " Server Thumbprint: $($importedServer.Thumbprint)" -ForegroundColor DarkGray } +Write-Host " 证书导入完成" -ForegroundColor Green + +Write-Host "[6/17] 创建本地用户..." -ForegroundColor Yellow +$SecurePassword = ConvertTo-SecureString $NtlmPassword -AsPlainText -Force +if (-not (Get-LocalUser -Name $NtlmUser -ErrorAction SilentlyContinue)) { + New-LocalUser -Name $NtlmUser -Password $SecurePassword -Description "2c2a WinRM Certificate Auth User" +} else { + Set-LocalUser -Name $NtlmUser -Password $SecurePassword +} +Add-LocalGroupMember -Group "Administrators" -Member $NtlmUser -ErrorAction SilentlyContinue +if ($Debug) { Write-Host " 用户: $NtlmUser" -ForegroundColor DarkGray } +Write-Host " 用户 $NtlmUser 已创建" -ForegroundColor Green + +Write-Host "[7/17] 配置HTTPS监听器..." -ForegroundColor Yellow +Get-ChildItem WSMan:\localhost\Listener | Where-Object { $_.Keys -match "Transport=HTTPS" } | Remove-Item -Recurse -Force +New-Item -Path WSMan:\localhost\Listener -Transport HTTPS -Address * -CertificateThumbprint $importedServer.Thumbprint -Force +if ($Debug) { Write-Host " Thumbprint: $($importedServer.Thumbprint)" -ForegroundColor DarkGray } +Write-Host " 监听器已绑定: $($importedServer.Thumbprint)" -ForegroundColor Green + +Write-Host "[8/17] 配置客户端证书映射..." -ForegroundColor Yellow +Get-ChildItem WSMan:\localhost\ClientCertificate | Remove-Item -Recurse -Force +$cred = New-Object System.Management.Automation.PSCredential($NtlmUser, $SecurePassword) +New-Item -Path WSMan:\localhost\ClientCertificate -Subject $UpnValue -Issuer $importedCa.Thumbprint -Credential $cred -Force +if ($Debug) { Write-Host " Subject=$UpnValue Issuer=$($importedCa.Thumbprint)" -ForegroundColor DarkGray } +Write-Host " 映射已建立: Subject=$UpnValue" -ForegroundColor Green + +Write-Host "[9/17] 配置Schannel注册表..." -ForegroundColor Yellow +reg add "HKLM\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL" /v ClientAuthTrustMode /t REG_DWORD /d 2 /f +reg add "HKLM\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL" /v SendTrustedIssuerList /t REG_DWORD /d 0 /f +if ($Debug) { Write-Host " ClientAuthTrustMode=2, SendTrustedIssuerList=0" -ForegroundColor DarkGray } +Write-Host " ClientAuthTrustMode=2, SendTrustedIssuerList=0" -ForegroundColor Green + +Write-Host "[10/17] 启用证书认证..." -ForegroundColor Yellow +Set-Item -Path WSMan:\localhost\Service\Auth\Certificate -Value $true +Write-Host " 证书认证已启用" -ForegroundColor Green + +Write-Host "[11/17] 配置防火墙..." -ForegroundColor Yellow +$FirewallRuleName = "WinRM HTTPS (5986)" +$existingRule = Get-NetFirewallRule -DisplayName $FirewallRuleName -ErrorAction SilentlyContinue +if (-not $existingRule) { + New-NetFirewallRule -DisplayName $FirewallRuleName -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5986 +} else { + Enable-NetFirewallRule -DisplayName $FirewallRuleName +} +if ($Debug) { Write-Host " 规则: $FirewallRuleName" -ForegroundColor DarkGray } +Write-Host " 防火墙已配置" -ForegroundColor Green + +Write-Host "[12/17] 重启WinRM服务..." -ForegroundColor Yellow +Restart-Service WinRM -Force +Write-Host " WinRM服务已重启" -ForegroundColor Green + +Write-Host "[13/17] 通知服务器配置完成..." -ForegroundColor Yellow +$notifyBody = @{ token = $Token } | ConvertTo-Json +$notifyResp = $null +$notifyOk = $false +for ($i = 1; $i -le 3; $i++) { + try { + $notifyResp = Invoke-RestMethod -Uri "$ServerUrl/bootstrap/api/cert-provision/notify-complete/" -Method Post -Body $notifyBody -ContentType "application/json" + $notifyOk = $true + break + } catch { + if ($i -lt 3) { Start-Sleep -Seconds 5 } + } +} +$testDeferred = $false +if ($notifyOk) { + if ($notifyResp.test -eq "deferred") { + Write-Host " 已通知服务器(连接测试将在主机注册后执行)" -ForegroundColor Green + $testDeferred = $true + } else { + Write-Host " 已通知服务器" -ForegroundColor Green + } +} else { + Write-Host " 通知服务器失败,但本地配置已完成" -ForegroundColor Yellow + $testDeferred = $true +} + +if ($testDeferred) { + Write-Host "[14/17] 连接测试已延后,请在后台完成主机注册" -ForegroundColor Yellow +} else { + Write-Host "[14/17] 等待连接测试..." -ForegroundColor Yellow + $testResult = $null + $testWaited = 0 + $maxTestWait = 60 + while ($testWaited -lt $maxTestWait) { + Start-Sleep -Seconds 5 + $testWaited += 5 + try { + $testResp = Invoke-RestMethod -Uri "$ServerUrl/bootstrap/api/cert-provision/test-result/?token=$Token" -Method Get + if ($testResp.status -ne "testing") { + $testResult = $testResp + break + } + } catch { + continue + } + Write-Host " 测试中... ($testWaited/${maxTestWait}s)" -ForegroundColor DarkGray + } + if ($testResult -and $testResult.status -eq "success") { + Write-Host " 连接测试成功!" -ForegroundColor Green + } else { + Write-Host " 连接测试失败或超时" -ForegroundColor Yellow + } +} + +Write-Host "[15/17] 安全性提升选项" -ForegroundColor Yellow +$choice = Read-Host "是否禁用密码认证以提升安全性?(Y/N)" +if ($choice -eq "Y" -or $choice -eq "y") { + Write-Host "[16/17] 禁用密码认证..." -ForegroundColor Yellow + Set-Item -Path WSMan:\localhost\Service\Auth\Basic -Value $false + Set-Item -Path WSMan:\localhost\Service\Auth\Digest -Value $false + Set-Item -Path WSMan:\localhost\Service\Auth\Kerberos -Value $false + Set-Item -Path WSMan:\localhost\Service\Auth\CredSSP -Value $false + Set-Item -Path WSMan:\localhost\Service\AllowUnencrypted -Value $false + Restart-Service WinRM -Force + $disableBody = @{ token = $Token } | ConvertTo-Json + Invoke-RestMethod -Uri "$ServerUrl/bootstrap/api/cert-provision/disable-password-auth/" -Method Post -Body $disableBody -ContentType "application/json" + Write-Host " 已禁用密码认证,仅允许证书认证" -ForegroundColor Green +} else { + Write-Host " 保留密码认证" -ForegroundColor Yellow +} + +Write-Host "[17/17] 清理临时文件..." -ForegroundColor Yellow +Remove-Item -Path $TempDir -Recurse -Force -ErrorAction SilentlyContinue +Write-Host "`n=== 配置完成 ===" -ForegroundColor Cyan diff --git a/templates/accounts/forgot_password.html b/templates/accounts/forgot_password.html index 51cc1f7..da06c3f 100755 --- a/templates/accounts/forgot_password.html +++ b/templates/accounts/forgot_password.html @@ -50,12 +50,8 @@

重置密码

mail - {% if CAPTCHA_PROVIDER == 'geetest' %} - - {% elif CAPTCHA_PROVIDER == 'turnstile' %} - - {% elif CAPTCHA_PROVIDER == 'local' %} - + {% if CAPTCHA_PROVIDER == 'tianai' %} + {% else %} {% endif %} @@ -89,25 +85,11 @@

重置密码

{% endif %}
- - - - - +
- {% if CAPTCHA_PROVIDER == 'geetest' %} - - {% elif CAPTCHA_PROVIDER == 'turnstile' %} - - {% elif CAPTCHA_PROVIDER == 'local' %} - @@ -129,23 +111,22 @@

重置密码

- - {% if CAPTCHA_PROVIDER == 'geetest' %} - - {% elif CAPTCHA_PROVIDER == 'local' %} - - {% elif CAPTCHA_PROVIDER == 'turnstile' %} - + {% if CAPTCHA_PROVIDER == 'tianai' %} + {% load static %} + + + {% endif %} diff --git a/templates/accounts/login.html b/templates/accounts/login.html index 25616b3..41a67de 100644 --- a/templates/accounts/login.html +++ b/templates/accounts/login.html @@ -79,18 +79,8 @@

欢迎回来

- {% if CAPTCHA_PROVIDER == 'geetest' %} - - {% elif CAPTCHA_PROVIDER == 'turnstile' %} - - {% elif CAPTCHA_PROVIDER == 'local' %} - @@ -102,11 +92,7 @@

欢迎回来

{% endif %}
- - - - - +
@@ -119,23 +105,19 @@

欢迎回来

- - {% if CAPTCHA_PROVIDER == 'geetest' %} - - {% elif CAPTCHA_PROVIDER == 'local' %} - - {% elif CAPTCHA_PROVIDER == 'turnstile' %} - + {% if CAPTCHA_PROVIDER == 'tianai' %} + {% load static %} + + + {% endif %} diff --git a/templates/accounts/register.html b/templates/accounts/register.html index 747bf3c..21d02ee 100644 --- a/templates/accounts/register.html +++ b/templates/accounts/register.html @@ -47,10 +47,7 @@

{% if reglink %}邀请
{% csrf_token %} - - - - +
@@ -76,12 +73,8 @@

{% if reglink %}邀请
mail - {% if CAPTCHA_PROVIDER == 'geetest' %} - - {% elif CAPTCHA_PROVIDER == 'turnstile' %} - - {% elif CAPTCHA_PROVIDER == 'local' %} - + {% if CAPTCHA_PROVIDER == 'tianai' %} + {% else %} {% endif %} @@ -138,23 +131,22 @@

{% if reglink %}邀请 - - {% if CAPTCHA_PROVIDER == 'geetest' %} - - {% elif CAPTCHA_PROVIDER == 'local' %} - - {% elif CAPTCHA_PROVIDER == 'turnstile' %} - + {% if CAPTCHA_PROVIDER == 'tianai' %} + {% load static %} + + + {% endif %} diff --git a/templates/admin_base/dashboard/systemconfig_edit.html b/templates/admin_base/dashboard/systemconfig_edit.html index c49dfd7..19f8eed 100644 --- a/templates/admin_base/dashboard/systemconfig_edit.html +++ b/templates/admin_base/dashboard/systemconfig_edit.html @@ -123,7 +123,7 @@

verified_user 验证码设置

-
+
{% with field=form.captcha_provider %} {% for value, label in form.fields.captcha_provider.choices %} @@ -131,11 +131,39 @@

{% endfor %} {% endwith %} - {% with field=form.captcha_id %} - + {% with field=form.captcha_type %} + + {% for value, label in form.fields.captcha_type.choices %} + + {% endfor %} + + {% endwith %} +

+

场景级别类型留空则使用默认类型

+
+ {% with field=form.login_captcha_type %} + + + {% for value, label in form.fields.login_captcha_type.choices %} + + {% endfor %} + + {% endwith %} + {% with field=form.register_captcha_type %} + + + {% for value, label in form.fields.register_captcha_type.choices %} + + {% endfor %} + {% endwith %} - {% with field=form.captcha_key %} - + {% with field=form.email_captcha_type %} + + + {% for value, label in form.fields.email_captcha_type.choices %} + + {% endfor %} + {% endwith %}
diff --git a/templates/admin_base/hosts/host_detail.html b/templates/admin_base/hosts/host_detail.html index 03b4661..e419e83 100644 --- a/templates/admin_base/hosts/host_detail.html +++ b/templates/admin_base/hosts/host_detail.html @@ -136,6 +136,51 @@

{{ host.name }}

{% endif %} +{% if host.auth_method == 'certificate' %} + +
+
+

在目标主机上运行配置指令,自动完成证书签发与部署。

+ +
+ + + + +
+
+{% endif %} + {% if host.connection_type == 'tunnel' %} @@ -239,6 +284,80 @@

{{ host.name }}

暂无关联产品

{% endif %}
+ + +{% if host.auth_method == 'certificate' %} + +
+

+ 在目标主机上以管理员权限运行以下命令,自动完成 H 端初始化和证书配置。 +

+ + + + + + +
+
+{% endif %}
{% endblock %} diff --git a/templates/admin_base/hosts/host_form.html b/templates/admin_base/hosts/host_form.html index e440c2a..7c1d3ac 100644 --- a/templates/admin_base/hosts/host_form.html +++ b/templates/admin_base/hosts/host_form.html @@ -10,199 +10,657 @@ {% endblock %} {% block content %} - -
-
-

{% if is_create %}创建主机{% else %}编辑主机{% endif %}

-

- {% if is_create %}填写以下信息创建新主机{% else %}修改主机「{{ host.name }}」的信息{% endif %} -

+
+
+
+

{% if is_create %}创建主机{% else %}编辑主机{% endif %}

+

+ {% if is_create %}填写以下信息创建新主机{% else %}修改主机「{{ host.name }}」的信息{% endif %} +

+
+ + arrow_back + 返回列表 +
- - arrow_back - 返回列表 - -
- - - - {% csrf_token %} - - - {% if form.non_field_errors %} -
- {% for error in form.non_field_errors %} -

{{ error }}

- {% endfor %} -
- {% endif %} + + + {% csrf_token %} + + {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %} +

{{ error }}

+ {% endfor %} +
+ {% endif %} + +
+

+ dns + 基本信息 +

+
+
+ + + {% if form.name.errors %} +
+ {% for error in form.name.errors %} +

{{ error }}

+ {% endfor %} +
+ {% endif %} +
+ +
+ +
+ + expand_more +
+ {% if form.os_type.errors %} +
+ {% for error in form.os_type.errors %} +

{{ error }}

+ {% endfor %} +
+ {% endif %} +
+ +
+ + + {% if form.hostname.errors %} +
+ {% for error in form.hostname.errors %} +

{{ error }}

+ {% endfor %} +
+ {% endif %} +
+ +
+ +
+ + +
+ + {% if form.connection_type.errors %} +
+ {% for error in form.connection_type.errors %} +

{{ error }}

+ {% endfor %} +
+ {% endif %} +
+
+
+ +
+

+ settings_ethernet + 连接配置 +

+
+
+
+ + +

+ {% if form.port.errors %} +
+ {% for error in form.port.errors %} +

{{ error }}

+ {% endfor %} +
+ {% endif %} +
+ +
+ + + {% if form.rdp_port.errors %} +
+ {% for error in form.rdp_port.errors %} +

{{ error }}

+ {% endfor %} +
+ {% endif %} +
+
- -
-

- dns - 基本信息 -

-
- {% with field=form.name %} - - {% endwith %} - - {% with field=form.hostname %} - - {% endwith %} - - {% with field=form.connection_type %} - - {% for value, label in form.fields.connection_type.choices %} - - {% endfor %} - - {% endwith %} - - {% with field=form.port %} - - {% endwith %} - - {% with field=form.rdp_port %} - - {% endwith %} - -
-
- + + (端口5986通常需要SSL)
-
-
-
- -
-

- key - 认证信息 -

-
- {% with field=form.username %} - - {% endwith %} - - {% with field=form.password %} - - {% endwith %} -
-
+
+ +
+ + +
+ +
- {% plugin_extensions "host_form_after_auth" %} - - -
-

- group - 提供商分配 -

- - -
-
- - -
-
- - - +
+ +
+ +
+
+ + + {% if form.username.errors %} +
+ {% for error in form.username.errors %} +

{{ error }}

+ {% endfor %} +
+ {% endif %} +
+ +
+ +
+ + +
+ {% if host %}

留空则不修改密码

{% endif %} + {% if form.password.errors %} +
+ {% for error in form.password.errors %} +

{{ error }}

+ {% endfor %} +
+ {% endif %} +
+
+ +
+
+ +
+ + +
+
+ +
+ +
+

1. 复制下方PowerShell脚本

+

2. 在目标主机上以管理员权限运行

+

3. 脚本将自动配置WinRM证书认证并导出证书文件

+

4. 将导出的证书文件上传到下方

+
+
+
+
+ + +
+
+
+ terminal + PowerShell +
+
+ +
+
+
+
+ +
+ +
+

请上传PEM格式的客户端证书和私钥文件

+

证书文件需包含公钥,私钥文件需包含完整的私钥数据

+
+
+
+ +
+
+ + +

+ {% if host and host.cert_pem_path %} +

当前已存储证书文件

+ {% endif %} + {% if form.cert_pem.errors %} +
+ {% for error in form.cert_pem.errors %} +

{{ error }}

+ {% endfor %} +
+ {% endif %} +
+ +
+ + +

+ {% if host and host.cert_key_path %} +

当前已存储私钥文件

+ {% endif %} + {% if form.cert_key.errors %} +
+ {% for error in form.cert_key.errors %} +

{{ error }}

+ {% endfor %} +
+ {% endif %} +
+
+
-
- -
- - - - - - - - - - - - - - -
类型名称操作
- person_off -

暂未分配提供商,请使用上方表单添加

-
-
+ + +
- - -
+ +
- {% plugin_extensions "host_form_after_providers" %} + {% plugin_extensions "host_form_after_providers" %} - -
- - 取消 - - - {% if is_create %}创建{% else %}保存{% endif %} - -
- -
+
+ + 取消 + + + {% if is_create %}创建{% else %}保存{% endif %} + +
+ +
+
{% endblock %} {% block extra_js %} -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/templates/admin_base/providers/host_provider_assign.html b/templates/admin_base/providers/host_provider_assign.html index 287e380..d55976b 100644 --- a/templates/admin_base/providers/host_provider_assign.html +++ b/templates/admin_base/providers/host_provider_assign.html @@ -52,61 +52,175 @@

分配提供商 - {{ host.name }}

- - {% if current_providers %} -
- {% for provider in current_providers %} -
- person - {{ provider.username }} - {% if provider.email %} - ({{ provider.email }}) - {% endif %} + +
+ {% csrf_token %} + + {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %} +

{{ error }}

+ {% endfor %}
- {% endfor %} -
- {% else %} -
- info - 尚未分配任何提供商 -
- {% endif %} - + {% endif %} - - - 选择可以管理此主机的提供商用户。提供商可以查看和管理分配给他们的主机及其相关资源。按住 Ctrl/Cmd 可多选。 - +
+
+
+ + +
+
+ + + +
+ +
- - {% csrf_token %} -
- - {{ form.providers }} - {% if form.providers.help_text %} -

{{ form.providers.help_text }}

- {% endif %} - {% if form.providers.errors %} -
- {% for error in form.providers.errors %} -

{{ error }}

- {% endfor %} +
+ + + + + + + + + + + + + + +
类型名称操作
+ person_off +

暂未分配提供商,请使用上方表单添加

+
- {% endif %} + +
-
+
- - 取消 - + 取消 - + 保存分配
{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/cotton/x_admin_alert.html b/templates/cotton/x_admin_alert.html index 3777787..63976db 100644 --- a/templates/cotton/x_admin_alert.html +++ b/templates/cotton/x_admin_alert.html @@ -1,21 +1,19 @@ - - -{% with alert_type=type|default:variant %} +{% firstof attrs.type attrs.variant "info" as alert_type %} {% if alert_type == "success" %}
check_circle
- {% if title %} -

{{ title }}

+ {% if attrs.title %} +

{{ attrs.title }}

{% endif %}
{{ slot }}
- {% if dismissible == "true" %} + {% if attrs.dismissible == "true" %} @@ -24,19 +22,19 @@ {% elif alert_type == "error" %}
error
- {% if title %} -

{{ title }}

+ {% if attrs.title %} +

{{ attrs.title }}

{% endif %}
{{ slot }}
- {% if dismissible == "true" %} + {% if attrs.dismissible == "true" %} @@ -45,19 +43,19 @@ {% elif alert_type == "warning" %}
warning
- {% if title %} -

{{ title }}

+ {% if attrs.title %} +

{{ attrs.title }}

{% endif %}
{{ slot }}
- {% if dismissible == "true" %} + {% if attrs.dismissible == "true" %} @@ -66,23 +64,22 @@ {% else %}
info
- {% if title %} -

{{ title }}

+ {% if attrs.title %} +

{{ attrs.title }}

{% endif %}
{{ slot }}
- {% if dismissible == "true" %} + {% if attrs.dismissible == "true" %} {% endif %}
{% endif %} -{% endwith %} diff --git a/templates/cotton/x_admin_card.html b/templates/cotton/x_admin_card.html index f4b658b..3c03d61 100644 --- a/templates/cotton/x_admin_card.html +++ b/templates/cotton/x_admin_card.html @@ -1,5 +1,3 @@ - -
{% if attrs.title or attrs.icon %}
@@ -16,9 +14,9 @@

{{ attrs.title }}

{{ slot }}
- {% if footer %} + {% if attrs.footer %}
- {{ footer }} + {{ attrs.footer }}
{% endif %}
diff --git a/templates/dashboard/system_config.html b/templates/dashboard/system_config.html index 6d1a6b2..7d6804f 100755 --- a/templates/dashboard/system_config.html +++ b/templates/dashboard/system_config.html @@ -143,25 +143,46 @@
验证码设置
{{ error }}
{% endfor %}
+
+ + {{ form.captcha_type }} + {% if form.captcha_type.help_text %} + {{ form.captcha_type.help_text }} + {% endif %} + {% for error in form.captcha_type.errors %} +
{{ error }}
+ {% endfor %} +
-
+

场景级别类型留空则使用默认类型

+
+
+ + {{ form.login_captcha_type }} + {% if form.login_captcha_type.help_text %} + {{ form.login_captcha_type.help_text }} + {% endif %} + {% for error in form.login_captcha_type.errors %} +
{{ error }}
+ {% endfor %} +
- - {{ form.captcha_id }} - {% if form.captcha_id.help_text %} - {{ form.captcha_id.help_text }} + + {{ form.register_captcha_type }} + {% if form.register_captcha_type.help_text %} + {{ form.register_captcha_type.help_text }} {% endif %} - {% for error in form.captcha_id.errors %} + {% for error in form.register_captcha_type.errors %}
{{ error }}
{% endfor %}
- - {{ form.captcha_key }} - {% if form.captcha_key.help_text %} - {{ form.captcha_key.help_text }} + + {{ form.email_captcha_type }} + {% if form.email_captcha_type.help_text %} + {{ form.email_captcha_type.help_text }} {% endif %} - {% for error in form.captcha_key.errors %} + {% for error in form.email_captcha_type.errors %}
{{ error }}
{% endfor %}
diff --git a/templates/operations/invite_result.html b/templates/operations/invite_result.html index 813b28a..942ecd7 100644 --- a/templates/operations/invite_result.html +++ b/templates/operations/invite_result.html @@ -10,7 +10,22 @@ {% block content %}
- {% if success %} + {% if needs_confirm %} + lock_open +

确认解锁

+

{{ message }}

+
+ {% csrf_token %} +
+ + + 取消 + +
+
+ {% elif success %} check_circle

邀请成功

{{ message }}

@@ -60,11 +75,13 @@

错误详情:

{% endif %} {% endif %} + {% if not needs_confirm %} + {% endif %}
{% endblock %} diff --git a/utils/ca_bundle.py b/utils/ca_bundle.py new file mode 100644 index 0000000..237d489 --- /dev/null +++ b/utils/ca_bundle.py @@ -0,0 +1,454 @@ +#!/usr/bin/env python3 +""" +WinRM PKI 证书生成器 + +生成 WinRM HTTPS 所需的 CA、服务器、客户端证书(含 UPN SAN)。 +依赖: pip install cryptography +""" + +import datetime +import ipaddress +import shutil +from pathlib import Path + +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.serialization import ( + BestAvailableEncryption, + Encoding, + NoEncryption, + PrivateFormat, + pkcs12, +) +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID, ObjectIdentifier + +# ============================================================ +# 配置项(按需修改) +# ============================================================ +WINDOWS_IP = "192.168.122.234" +WINDOWS_HOSTNAME = "WIN-R8S5ITT8IC9" +OUTPUT_DIR = "winrm-pki" +VALIDITY_DAYS = 3650 +PFX_PASSWORD = b"changeit" +UPN_VALUE = "test@localhost" +UPN_OID = "1.3.6.1.4.1.311.20.2.3" + + +# ============================================================ +# 基础工具函数 +# ============================================================ + +def ensure_output_dir(output_dir: str) -> Path: + """确保输出目录存在并切换工作目录。""" + path = Path(output_dir) + path.mkdir(parents=True, exist_ok=True) + import os + os.chdir(path) + return path + + +def generate_ec_key() -> ec.EllipticCurvePrivateKey: + """生成 EC 私钥(prime256v1 / P-256 曲线)。""" + return ec.generate_private_key(ec.SECP256R1(), default_backend()) + + +def save_private_key(key: ec.EllipticCurvePrivateKey, filename: str) -> None: + """将私钥保存为 PEM 文件(SEC1 格式,与 openssl ecparam 输出一致)。""" + pem = key.private_bytes( + encoding=Encoding.PEM, + format=PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=NoEncryption(), + ) + Path(filename).write_bytes(pem) + print(f" 已保存私钥: {filename}") + + +def save_certificate(cert: x509.Certificate, filename: str) -> None: + """将证书保存为 PEM 文件。""" + pem = cert.public_bytes(Encoding.PEM) + Path(filename).write_bytes(pem) + print(f" 已保存证书: {filename}") + + +def export_pfx( + cert: x509.Certificate, + key: ec.EllipticCurvePrivateKey, + ca_cert: x509.Certificate, + password: bytes, + filename: str, +) -> None: + """将证书 + 私钥 + CA 证书导出为 PKCS#12 (.pfx) 文件。""" + pfx_data = pkcs12.serialize_key_and_certificates( + name=None, + key=key, + cert=cert, + cas=[ca_cert], + encryption_algorithm=BestAvailableEncryption(password), + ) + Path(filename).write_bytes(pfx_data) + print(f" 已导出 PFX: {filename}") + + +def _validity_period(): + """返回证书的有效期起止时间。""" + now = datetime.datetime.now(datetime.timezone.utc) + return now, now + datetime.timedelta(days=VALIDITY_DAYS) + + +def _encode_upn_other_name(upn: str) -> x509.OtherName: + """ + 编码 UPN OtherName(OID 1.3.6.1.4.1.311.20.2.3)。 + 值为 DER 编码的 UTF8String。 + """ + oid = ObjectIdentifier(UPN_OID) + encoded = upn.encode("utf-8") + # DER: tag(0x0C=UTF8String) + length + value + der_value = b"\x0c" + bytes([len(encoded)]) + encoded + return x509.OtherName(oid, der_value) + + +# ============================================================ +# 步骤 1:创建 CA +# ============================================================ + +def build_ca_certificate(ca_key: ec.EllipticCurvePrivateKey) -> x509.Certificate: + """创建自签名 CA 证书(对应 bash 中 ca.cnf 的扩展配置)。""" + subject = issuer = x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, "WinRM-CA"), + ]) + not_before, not_after = _validity_period() + + builder = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(ca_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(not_before) + .not_valid_after(not_after) + # basicConstraints = critical, CA:TRUE + .add_extension( + x509.BasicConstraints(ca=True, path_length=None), critical=True + ) + # keyUsage = critical, digitalSignature, keyCertSign, cRLSign + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_cert_sign=True, + crl_sign=True, + content_commitment=False, + key_encipherment=False, + data_encipherment=False, + key_agreement=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + # subjectKeyIdentifier = hash + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), + critical=False, + ) + # authorityKeyIdentifier = keyid:always, issuer + .add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), + critical=False, + ) + ) + return builder.sign(ca_key, hashes.SHA256(), default_backend()) + + +def create_ca() -> tuple[ec.EllipticCurvePrivateKey, x509.Certificate]: + """步骤 1:创建 CA 私钥和自签名证书。""" + print("\n" + "=" * 50) + print("1. 创建 CA") + print("=" * 50) + + ca_key = generate_ec_key() + save_private_key(ca_key, "ca.key") + + ca_cert = build_ca_certificate(ca_key) + save_certificate(ca_cert, "ca.crt") + + print_ca_info(ca_cert) + return ca_key, ca_cert + + +# ============================================================ +# 步骤 2:签发服务器证书 +# ============================================================ + +def build_server_csr( + server_key: ec.EllipticCurvePrivateKey, hostname: str +) -> x509.CertificateSigningRequest: + """生成服务器证书签名请求。""" + builder = x509.CertificateSigningRequestBuilder().subject_name( + x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hostname)]) + ) + return builder.sign(server_key, hashes.SHA256(), default_backend()) + + +def sign_server_certificate( + csr: x509.CertificateSigningRequest, + ca_key: ec.EllipticCurvePrivateKey, + ca_cert: x509.Certificate, + hostname: str, + ip_address: str, +) -> x509.Certificate: + """用 CA 签发服务器证书(对应 bash 中 server_ext.cnf 的扩展配置)。""" + not_before, not_after = _validity_period() + + san = x509.SubjectAlternativeName([ + x509.DNSName(hostname), + x509.IPAddress(ipaddress.ip_address(ip_address)), + ]) + + builder = ( + x509.CertificateBuilder() + .subject_name(csr.subject) + .issuer_name(ca_cert.subject) + .public_key(csr.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(not_before) + .not_valid_after(not_after) + # basicConstraints = CA:FALSE + .add_extension( + x509.BasicConstraints(ca=False, path_length=None), critical=False + ) + # keyUsage = critical, digitalSignature, keyEncipherment + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_encipherment=True, + content_commitment=False, + key_agreement=False, + key_cert_sign=False, + crl_sign=False, + data_encipherment=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + # extendedKeyUsage = serverAuth + .add_extension( + x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), + critical=False, + ) + # subjectAltName + .add_extension(san, critical=False) + # subjectKeyIdentifier = hash + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(csr.public_key()), + critical=False, + ) + # authorityKeyIdentifier = keyid,issuer + .add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), + critical=False, + ) + ) + return builder.sign(ca_key, hashes.SHA256(), default_backend()) + + +def create_server_cert( + ca_key: ec.EllipticCurvePrivateKey, + ca_cert: x509.Certificate, +) -> tuple[ec.EllipticCurvePrivateKey, x509.Certificate]: + """步骤 2:签发服务器证书。""" + print("\n" + "=" * 50) + print("2. 签发服务器证书") + print("=" * 50) + + server_key = generate_ec_key() + save_private_key(server_key, "server.key") + + csr = build_server_csr(server_key, WINDOWS_HOSTNAME) + server_cert = sign_server_certificate( + csr, ca_key, ca_cert, WINDOWS_HOSTNAME, WINDOWS_IP + ) + save_certificate(server_cert, "server.crt") + + export_pfx(server_cert, server_key, ca_cert, PFX_PASSWORD, "server.pfx") + + print_server_info(server_cert) + return server_key, server_cert + + +# ============================================================ +# 步骤 3:签发客户端证书(含 UPN SAN) +# ============================================================ + +def build_client_csr( + client_key: ec.EllipticCurvePrivateKey, +) -> x509.CertificateSigningRequest: + """生成客户端证书签名请求。""" + builder = x509.CertificateSigningRequestBuilder().subject_name( + x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "winrm-client")]) + ) + return builder.sign(client_key, hashes.SHA256(), default_backend()) + + +def sign_client_certificate( + csr: x509.CertificateSigningRequest, + ca_key: ec.EllipticCurvePrivateKey, + ca_cert: x509.Certificate, +) -> x509.Certificate: + """用 CA 签发客户端证书(对应 bash 中 client_ext.cnf,含 UPN SAN)。""" + not_before, not_after = _validity_period() + + san = x509.SubjectAlternativeName([_encode_upn_other_name(UPN_VALUE)]) + + builder = ( + x509.CertificateBuilder() + .subject_name(csr.subject) + .issuer_name(ca_cert.subject) + .public_key(csr.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(not_before) + .not_valid_after(not_after) + # basicConstraints = CA:FALSE + .add_extension( + x509.BasicConstraints(ca=False, path_length=None), critical=False + ) + # keyUsage = critical, digitalSignature + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_encipherment=False, + content_commitment=False, + key_agreement=False, + key_cert_sign=False, + crl_sign=False, + data_encipherment=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + # extendedKeyUsage = clientAuth + .add_extension( + x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), + critical=False, + ) + # subjectKeyIdentifier = hash + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(csr.public_key()), + critical=False, + ) + # authorityKeyIdentifier = keyid,issuer + .add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), + critical=False, + ) + # ★ subjectAltName = UPN otherName ★ + .add_extension(san, critical=False) + ) + return builder.sign(ca_key, hashes.SHA256(), default_backend()) + + +def create_client_cert( + ca_key: ec.EllipticCurvePrivateKey, + ca_cert: x509.Certificate, +) -> tuple[ec.EllipticCurvePrivateKey, x509.Certificate]: + """步骤 3:签发客户端证书。""" + print("\n" + "=" * 50) + print("3. 签发客户端证书") + print("=" * 50) + + client_key = generate_ec_key() + save_private_key(client_key, "client.key") + + csr = build_client_csr(client_key) + client_cert = sign_client_certificate(csr, ca_key, ca_cert) + save_certificate(client_cert, "client.crt") + + # 复制为兼容命名的 PEM 文件 + shutil.copy2("client.crt", "client-cert.pem") + shutil.copy2("client.key", "client-key.pem") + print(" 已复制: client.crt -> client-cert.pem") + print(" 已复制: client.key -> client-key.pem") + + export_pfx(client_cert, client_key, ca_cert, PFX_PASSWORD, "client.pfx") + + print_client_info(client_cert) + return client_key, client_cert + + +# ============================================================ +# 证书信息打印 +# ============================================================ + +def print_ca_info(cert: x509.Certificate) -> None: + """打印 CA 证书关键信息。""" + print("\n=== CA 证书 ===") + print(f" Subject: {cert.subject.rfc4514_string()}") + for ext in cert.extensions: + if isinstance(ext.value, x509.BasicConstraints): + print(f" Basic Constraints: CA={ext.value.ca}") + elif isinstance(ext.value, x509.KeyUsage): + usages = [] + if ext.value.digital_signature: + usages.append("digitalSignature") + if ext.value.key_cert_sign: + usages.append("keyCertSign") + if ext.value.crl_sign: + usages.append("cRLSign") + print(f" Key Usage: {', '.join(usages)}") + fingerprint = cert.fingerprint(hashes.SHA256()).hex(":") + print(f" SHA256 Fingerprint: {fingerprint}") + + +def print_server_info(cert: x509.Certificate) -> None: + """打印服务器证书关键信息。""" + print("\n=== 服务器证书 ===") + print(f" Subject: {cert.subject.rfc4514_string()}") + print(f" Issuer: {cert.issuer.rfc4514_string()}") + + +def print_client_info(cert: x509.Certificate) -> None: + """打印客户端证书关键信息(含 SAN)。""" + print("\n=== 客户端证书 ===") + print(f" Subject: {cert.subject.rfc4514_string()}") + print(f" Issuer: {cert.issuer.rfc4514_string()}") + print(" --- SAN ---") + for ext in cert.extensions: + if isinstance(ext.value, x509.SubjectAlternativeName): + for name in ext.value: + if isinstance(name, x509.OtherName): + print( + f" OtherName (OID {name.type_id.dotted_string}): " + f"{name.value!r}" + ) + + +def print_summary() -> None: + """打印最终汇总信息。""" + print("\n" + "=" * 50) + print("生成完毕!需要导入 Windows 的文件:") + print(" ca.crt → LocalMachine\\Root") + print(" server.pfx → LocalMachine\\My") + print(" client.crt → LocalMachine\\TrustedPeople") + print("=" * 50) + + +# ============================================================ +# 主入口 +# ============================================================ + +def main() -> None: + """主入口:按顺序执行 CA → 服务器证书 → 客户端证书。""" + ensure_output_dir(OUTPUT_DIR) + + ca_key, ca_cert = create_ca() + create_server_cert(ca_key, ca_cert) + create_client_cert(ca_key, ca_cert) + + print_summary() + + +if __name__ == "__main__": + main() diff --git a/utils/cert_service.py b/utils/cert_service.py new file mode 100644 index 0000000..b3c43f1 --- /dev/null +++ b/utils/cert_service.py @@ -0,0 +1,272 @@ +import datetime +import ipaddress +import secrets +import string + +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.serialization import ( + BestAvailableEncryption, + pkcs12, +) +from cryptography.x509.oid import ( + ExtendedKeyUsageOID, + NameOID, + ObjectIdentifier, +) + + +def generate_ec_key() -> ec.EllipticCurvePrivateKey: + return ec.generate_private_key(ec.SECP256R1(), default_backend()) + + +def generate_ca( + ca_name: str = "WinRM-CA", + validity_days: int = 3650, +) -> tuple[ec.EllipticCurvePrivateKey, x509.Certificate]: + ca_key = generate_ec_key() + subject = issuer = x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, ca_name), + ]) + now = datetime.datetime.now(datetime.timezone.utc) + not_before = now + not_after = now + datetime.timedelta(days=validity_days) + builder = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(ca_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(not_before) + .not_valid_after(not_after) + .add_extension( + x509.BasicConstraints(ca=True, path_length=None), + critical=True, + ) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_cert_sign=True, + crl_sign=True, + content_commitment=False, + key_encipherment=False, + data_encipherment=False, + key_agreement=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), + critical=False, + ) + .add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key( + ca_key.public_key() + ), + critical=False, + ) + ) + ca_cert = builder.sign(ca_key, hashes.SHA256(), default_backend()) + return ca_key, ca_cert + + +def issue_server_cert( + ca_key: ec.EllipticCurvePrivateKey, + ca_cert: x509.Certificate, + hostname: str, + ip_address: str | None = None, + validity_days: int = 3650, + pfx_password: str | None = None, +) -> dict: + server_key = generate_ec_key() + subject = x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, hostname), + ]) + now = datetime.datetime.now(datetime.timezone.utc) + not_before = now + not_after = now + datetime.timedelta(days=validity_days) + san_entries = [x509.DNSName(hostname)] + if ip_address: + try: + san_entries.append( + x509.IPAddress(ipaddress.ip_address(ip_address)) + ) + except ValueError: + # Invalid IP input is intentionally ignored; DNS SAN is still used. + pass + san = x509.SubjectAlternativeName(san_entries) + builder = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(ca_cert.subject) + .public_key(server_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(not_before) + .not_valid_after(not_after) + .add_extension( + x509.BasicConstraints(ca=False, path_length=None), + critical=False, + ) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_encipherment=True, + content_commitment=False, + key_agreement=False, + key_cert_sign=False, + crl_sign=False, + data_encipherment=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension( + x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), + critical=False, + ) + .add_extension(san, critical=False) + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(server_key.public_key()), + critical=False, + ) + .add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key( + ca_key.public_key() + ), + critical=False, + ) + ) + server_cert = builder.sign(ca_key, hashes.SHA256(), default_backend()) + if pfx_password is None: + pfx_password = generate_random_pfx_password() + pfx_data = export_pfx( + server_cert, server_key, ca_cert, + pfx_password.encode("utf-8"), + ) + return { + "server_key": server_key, + "server_cert": server_cert, + "pfx_data": pfx_data, + "pfx_password": pfx_password, + } + + +def issue_client_cert( + ca_key: ec.EllipticCurvePrivateKey, + ca_cert: x509.Certificate, + upn_value: str, + validity_days: int = 3650, +) -> tuple[ec.EllipticCurvePrivateKey, x509.Certificate]: + client_key = generate_ec_key() + subject = x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, "winrm-client"), + ]) + now = datetime.datetime.now(datetime.timezone.utc) + not_before = now + not_after = now + datetime.timedelta(days=validity_days) + san = x509.SubjectAlternativeName([ + encode_upn_other_name(upn_value), + ]) + builder = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(ca_cert.subject) + .public_key(client_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(not_before) + .not_valid_after(not_after) + .add_extension( + x509.BasicConstraints(ca=False, path_length=None), + critical=False, + ) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_encipherment=False, + content_commitment=False, + key_agreement=False, + key_cert_sign=False, + crl_sign=False, + data_encipherment=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension( + x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), + critical=False, + ) + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(client_key.public_key()), + critical=False, + ) + .add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key( + ca_key.public_key() + ), + critical=False, + ) + .add_extension(san, critical=False) + ) + client_cert = builder.sign(ca_key, hashes.SHA256(), default_backend()) + return client_key, client_cert + + +def encode_upn_other_name(upn: str) -> x509.OtherName: + oid = ObjectIdentifier("1.3.6.1.4.1.311.20.2.3") + encoded = upn.encode("utf-8") + der_value = b"\x0c" + bytes([len(encoded)]) + encoded + return x509.OtherName(oid, der_value) + + +def export_pfx( + cert: x509.Certificate, + key: ec.EllipticCurvePrivateKey, + ca_cert: x509.Certificate, + password_bytes: bytes, +) -> bytes: + return pkcs12.serialize_key_and_certificates( + name=None, + key=key, + cert=cert, + cas=[ca_cert], + encryption_algorithm=BestAvailableEncryption(password_bytes), + ) + + +def generate_random_pfx_password(length: int = 16) -> str: + alphabet = string.ascii_letters + string.digits + return "".join(secrets.choice(alphabet) for _ in range(length)) + + +def generate_random_username(prefix: str = "c2a_", length: int = 8) -> str: + alphabet = string.ascii_lowercase + string.digits + return prefix + "".join(secrets.choice(alphabet) for _ in range(length)) + + +def generate_random_password(length: int = 16) -> str: + if length < 4: + raise ValueError( + "Password length must be at least 4 " + "to meet complexity requirements" + ) + parts = [ + secrets.choice(string.ascii_uppercase), + secrets.choice(string.ascii_lowercase), + secrets.choice(string.digits), + secrets.choice("!@#$%^&*"), + ] + remaining = length - len(parts) + pool = string.ascii_letters + string.digits + "!@#$%^&*" + parts.extend(secrets.choice(pool) for _ in range(remaining)) + shuffled = list(parts) + for i in range(len(shuffled) - 1, 0, -1): + j = secrets.randbelow(i + 1) + shuffled[i], shuffled[j] = shuffled[j], shuffled[i] + return "".join(shuffled) diff --git a/utils/cert_storage.py b/utils/cert_storage.py new file mode 100644 index 0000000..6b2a4e6 --- /dev/null +++ b/utils/cert_storage.py @@ -0,0 +1,74 @@ +import os +import secrets +import shutil +from pathlib import Path +from django.conf import settings + + +def get_cert_base_dir(): + return Path(settings.MEDIA_ROOT) / 'certificates' + + +def get_cert_dir(cert_root: str, cert_sub: str): + return get_cert_base_dir() / cert_root / cert_sub + + +def generate_cert_paths(): + cert_root = secrets.token_hex(1) + cert_sub = secrets.token_hex(1) + return cert_root, cert_sub + + +def save_cert_files( + cert_root: str, + cert_sub: str, + ca_cert_pem: bytes, + client_cert_pem: bytes, + server_pfx_bytes: bytes, + client_key_pem: bytes | None = None, +): + cert_dir = get_cert_dir(cert_root, cert_sub) + cert_dir.mkdir(parents=True, exist_ok=True) + + ca_cert_path = cert_dir / 'ca.crt' + ca_cert_path.write_bytes(ca_cert_pem) + os.chmod(ca_cert_path, 0o600) + + client_cert_path = cert_dir / 'client.crt' + client_cert_path.write_bytes(client_cert_pem) + os.chmod(client_cert_path, 0o600) + + if client_key_pem: + client_key_path = cert_dir / 'client.key' + client_key_path.write_bytes(client_key_pem) + os.chmod(client_key_path, 0o600) + + server_pfx_path = cert_dir / 'server.pfx' + server_pfx_path.write_bytes(server_pfx_bytes) + os.chmod(server_pfx_path, 0o600) + + return cert_dir + + +def delete_cert_files(cert_root: str, cert_sub: str): + cert_dir = get_cert_dir(cert_root, cert_sub) + if cert_dir.exists(): + shutil.rmtree(cert_dir) + parent_dir = cert_dir.parent + try: + parent_dir.rmdir() + except OSError: + # Best-effort cleanup: parent may be non-empty or changed concurrently. + pass + return True + return False + + +def get_cert_file_paths(cert_root: str, cert_sub: str): + cert_dir = get_cert_dir(cert_root, cert_sub) + return { + 'ca_cert': cert_dir / 'ca.crt', + 'client_cert': cert_dir / 'client.crt', + 'client_key': cert_dir / 'client.key', + 'server_pfx': cert_dir / 'server.pfx', + } diff --git a/utils/crypto.py b/utils/crypto.py new file mode 100644 index 0000000..c382567 --- /dev/null +++ b/utils/crypto.py @@ -0,0 +1,28 @@ +import base64 +import hashlib +from django.conf import settings +from cryptography.fernet import Fernet + +_fernet_instance = None + +def get_fernet(): + global _fernet_instance + if _fernet_instance is None: + key = hashlib.sha256(settings.SECRET_KEY.encode()).digest() + _fernet_instance = Fernet(base64.urlsafe_b64encode(key)) + return _fernet_instance + +def encrypt_value(plaintext): + if not plaintext: + return '' + f = get_fernet() + return f.encrypt(plaintext.encode()).decode() + +def decrypt_value(ciphertext): + if not ciphertext: + return '' + f = get_fernet() + try: + return f.decrypt(ciphertext.encode()).decode() + except Exception: + raise ValueError("解密失败") diff --git a/utils/winrm_client.py b/utils/winrm_client.py index 9c1d46a..7170e56 100755 --- a/utils/winrm_client.py +++ b/utils/winrm_client.py @@ -85,13 +85,16 @@ class WinrmClient: def __init__( self, hostname: str, - username: str, - password: str, + username: Optional[str] = None, + password: Optional[str] = None, port: int = 5985, use_ssl: bool = False, + auth_method: str = 'ntlm', + cert_pem_path: Optional[str] = None, + cert_key_path: Optional[str] = None, timeout: Optional[int] = None, max_retries: Optional[int] = None, - server_cert_validation: str = 'ignore', + server_cert_validation: str = 'validate', ca_trust_path: Optional[str] = None, client_cert_pem: Optional[str] = None, client_cert_key: Optional[str] = None @@ -101,17 +104,48 @@ def __init__( 参数: hostname: 主机名或IP地址 - username: 登录用户名 - password: 登录密码 + username: 登录用户名(ntlm方式必填) + password: 登录密码(ntlm方式必填) port: WinRM服务端口,默认为5985 use_ssl: 是否使用SSL连接,默认为False + auth_method: 认证方式 ('ntlm', 'certificate') + cert_pem_path: 客户端证书PEM文件路径(certificate方式必填) + cert_key_path: 客户端私钥PEM文件路径(certificate方式必填) timeout: 操作超时时间(秒),默认使用配置文件中的值 max_retries: 最大重试次数,默认使用配置文件中的值 server_cert_validation: 服务器证书验证模式 ('ignore', 'validate') ca_trust_path: CA证书路径(用于验证服务器证书) - client_cert_pem: 客户端证书PEM文件路径 - client_cert_key: 客户端证书私钥文件路径 + client_cert_pem: 客户端证书PEM文件路径(已弃用,使用cert_pem_path) + client_cert_key: 客户端证书私钥文件路径(已弃用,使用cert_key_path) """ + if auth_method == 'certificate': + if not cert_pem_path and client_cert_pem: + cert_pem_path = client_cert_pem + if not cert_key_path and client_cert_key: + cert_key_path = client_cert_key + if not cert_pem_path or not cert_key_path: + raise ValueError("证书认证方式必须提供证书和私钥路径") + if not os.path.exists(cert_pem_path): + raise ValueError(f"客户端证书文件不存在: {cert_pem_path}") + if not os.path.exists(cert_key_path): + raise ValueError(f"客户端私钥文件不存在: {cert_key_path}") + self.auth_method = 'certificate' + self.cert_pem_path = cert_pem_path + self.cert_key_path = cert_key_path + self.username = username or '' + self.password = password or '' + elif auth_method == 'ntlm': + if not username: + raise ValueError("NTLM认证方式必须提供用户名") + if not password: + raise ValueError("NTLM认证方式必须提供密码") + self.auth_method = 'ntlm' + self.username = username + self.password = password + self.cert_pem_path = '' + self.cert_key_path = '' + else: + raise ValueError(f"不支持的认证方式: {auth_method}") # 检查主机名是否包含端口(例如 "hostname:port" 或 "ip:port" 格式) if ':' in hostname and not hostname.startswith('http'): # 分离主机名和端口 @@ -135,13 +169,10 @@ def __init__( self.hostname = hostname self.port = port - self.username = username - self.password = password self.use_ssl = use_ssl self.timeout = timeout or settings.WINRM_TIMEOUT self.max_retries = max_retries or settings.WINRM_MAX_RETRIES - # 证书验证配置 self.server_cert_validation = server_cert_validation self.ca_trust_path = ca_trust_path self.client_cert_pem = client_cert_pem @@ -153,7 +184,6 @@ def __init__( "存在中间人攻击风险" ) - # 验证证书配置 if use_ssl and server_cert_validation == 'validate': if not ca_trust_path: logger.warning("SSL验证启用但未提供CA证书路径,将使用系统默认证书") @@ -161,39 +191,46 @@ def __init__( logger.error(f"CA证书文件不存在: {ca_trust_path}") raise ValueError(f"CA证书文件不存在: {ca_trust_path}") - if client_cert_pem and not os.path.exists(client_cert_pem): - raise ValueError(f"客户端证书文件不存在: {client_cert_pem}") - - if client_cert_key and not os.path.exists(client_cert_key): - raise ValueError(f"客户端私钥文件不存在: {client_cert_key}") - - if (client_cert_pem and not client_cert_key) or (not client_cert_pem and client_cert_key): - raise ValueError("必须同时提供客户端证书和私钥文件") + if self.auth_method == 'certificate': + transport = 'certificate' + if not self.use_ssl: + self.use_ssl = True + if self.port == 5985: + self.port = 5986 + else: + transport = 'ntlm' protocol = 'https' if self.use_ssl else 'http' self.endpoint = f'{protocol}://{self.hostname}:{self.port}/wsman' - # 验证主机可达性 if not self._validate_hostname(): raise ValueError(f"主机名无法解析: {self.hostname}") - # 初始化会话对象 - self.session = Session( - self.endpoint, - auth=(self.username, self.password), - transport='ntlm', + session_kwargs = dict( + transport=transport, server_cert_validation=self.server_cert_validation, ca_trust_path=self.ca_trust_path or None, - cert_pem=self.client_cert_pem, - cert_key_pem=self.client_cert_key, - # 设置连接超时 operation_timeout_sec=self.timeout, - read_timeout_sec=self.timeout + 10 + read_timeout_sec=self.timeout + 10, ) + if self.auth_method == 'certificate': + session_kwargs['cert_pem'] = self.cert_pem_path + session_kwargs['cert_key_pem'] = self.cert_key_path + self.session = Session( + self.endpoint, + auth=(self.username, self.password), + **session_kwargs, + ) + else: + self.session = Session( + self.endpoint, + auth=(self.username, self.password), + **session_kwargs, + ) logger.info( f"初始化WinRM客户端: 主机={self.hostname}, 端口={self.port}, " - f"SSL={use_ssl}, 验证模式={server_cert_validation}, " + f"SSL={self.use_ssl}, 认证={self.auth_method}, " f"超时={self.timeout}秒, 最大重试={self.max_retries}次" ) diff --git a/uv.lock b/uv.lock index 0900c8a..908e47e 100644 --- a/uv.lock +++ b/uv.lock @@ -7,70 +7,34 @@ name = "2c2a" version = "1.0.0" source = { editable = "." } dependencies = [ - { name = "amqp" }, - { name = "asgiref" }, - { name = "billiard" }, - { name = "black" }, { name = "celery" }, - { name = "certifi" }, - { name = "cffi" }, - { name = "charset-normalizer" }, - { name = "click" }, - { name = "click-didyoumean" }, - { name = "click-plugins" }, - { name = "click-repl" }, + { name = "cotton-icons" }, { name = "cryptography" }, { name = "django" }, { name = "django-cors-headers" }, { name = "django-cotton" }, { name = "django-formtools" }, + { name = "django-tianai-captcha" }, { name = "djangorestframework" }, - { name = "idna" }, - { name = "kombu" }, + { name = "djlint" }, + { name = "heroicons" }, { name = "markdown" }, - { name = "msgpack" }, - { name = "packaging" }, { name = "pillow" }, - { name = "prompt-toolkit" }, - { name = "pycparser" }, { name = "pyjwt" }, - { name = "pymysql" }, { name = "pyotp" }, - { name = "pyspnego" }, - { name = "python-dateutil" }, { name = "python-dotenv" }, { name = "pywinrm" }, + { name = "redis" }, { name = "requests" }, - { name = "requests-ntlm" }, - { name = "six" }, - { name = "sqlalchemy" }, - { name = "sqlparse" }, { name = "toml" }, - { name = "typing-extensions" }, - { name = "tzdata" }, - { name = "urllib3" }, - { name = "vine" }, - { name = "wcwidth" }, - { name = "xmltodict" }, + { name = "whitenoise" }, ] [package.optional-dependencies] -dev = [ - { name = "black" }, - { name = "flake8" }, - { name = "pytest" }, - { name = "pytest-django" }, -] kerberos = [ { name = "gssapi" }, { name = "krb5" }, ] -postgresql = [ - { name = "psycopg2-binary" }, -] -redis = [ - { name = "redis" }, -] [package.dev-dependencies] dev = [ @@ -85,61 +49,33 @@ dev = [ [package.metadata] requires-dist = [ - { name = "amqp", specifier = "==5.3.1" }, - { name = "asgiref", specifier = "==3.8.1" }, - { name = "billiard", specifier = "==4.2.4" }, - { name = "black", specifier = ">=26.3.1" }, - { name = "black", marker = "extra == 'dev'" }, { name = "celery", specifier = "==5.4.0" }, - { name = "certifi", specifier = "==2026.1.4" }, - { name = "cffi", specifier = "==2.0.0" }, - { name = "charset-normalizer", specifier = "==3.4.4" }, - { name = "click", specifier = "==8.3.1" }, - { name = "click-didyoumean", specifier = "==0.3.1" }, - { name = "click-plugins", specifier = "==1.1.1.2" }, - { name = "click-repl", specifier = "==0.3.0" }, - { name = "cryptography", specifier = "==46.0.7" }, - { name = "django", specifier = "==4.2.30" }, + { name = "cotton-icons", specifier = ">=0.2.0" }, + { name = "cryptography", specifier = "==46.0.3" }, + { name = "django", specifier = "==4.2.27" }, { name = "django-cors-headers", specifier = "==4.3.1" }, { name = "django-cotton", git = "https://github.com/2c2a/django-cotton.git?rev=feature%2Fx-prefix-tag-support" }, { name = "django-formtools", specifier = ">=2.5.1" }, + { name = "django-tianai-captcha", git = "https://github.com/trustedinster/django-tianai-captcha.git" }, { name = "djangorestframework", specifier = "==3.15.2" }, - { name = "flake8", marker = "extra == 'dev'" }, + { name = "djlint", specifier = ">=1.36.4" }, { name = "gssapi", marker = "extra == 'kerberos'", specifier = ">=1.11.1" }, + { name = "heroicons", specifier = ">=2.14.0" }, { name = "idna", specifier = "==3.15" }, { name = "kombu", specifier = "==5.6.2" }, { name = "krb5", marker = "extra == 'kerberos'", specifier = ">=0.9.0" }, { name = "markdown", specifier = "==3.10.1" }, - { name = "msgpack", specifier = ">=1.0.0" }, - { name = "packaging", specifier = "==26.0" }, - { name = "pillow", specifier = "==12.2.0" }, - { name = "prompt-toolkit", specifier = "==3.0.52" }, - { name = "psycopg2-binary", marker = "extra == 'postgresql'", specifier = ">=2.9.9" }, - { name = "pycparser", specifier = "==3.0" }, + { name = "pillow", specifier = "==12.1.0" }, { name = "pyjwt", specifier = ">=2.8.0" }, - { name = "pymysql", specifier = ">=1.1.2" }, { name = "pyotp" }, - { name = "pyspnego", specifier = "==0.12.0" }, - { name = "pytest", marker = "extra == 'dev'" }, - { name = "pytest-django", marker = "extra == 'dev'" }, - { name = "python-dateutil", specifier = "==2.9.0.post0" }, - { name = "python-dotenv", specifier = "==1.2.2" }, + { name = "python-dotenv", specifier = "==1.2.1" }, { name = "pywinrm", specifier = "==0.4.3" }, - { name = "redis", marker = "extra == 'redis'", specifier = ">=5.0.0" }, - { name = "requests", specifier = "==2.33.0" }, - { name = "requests-ntlm", specifier = "==1.3.0" }, - { name = "six", specifier = "==1.17.0" }, - { name = "sqlalchemy", specifier = ">=2.0.0" }, - { name = "sqlparse", specifier = "==0.5.4" }, + { name = "redis", specifier = ">=5.0.0" }, + { name = "requests", specifier = "==2.32.3" }, { name = "toml" }, - { name = "typing-extensions", specifier = ">=4.15.0" }, - { name = "tzdata", specifier = "==2025.3" }, - { name = "urllib3", specifier = "==2.7.0" }, - { name = "vine", specifier = "==5.1.0" }, - { name = "wcwidth", specifier = "==0.3.2" }, - { name = "xmltodict", specifier = "==1.0.2" }, + { name = "whitenoise", specifier = ">=6.12.0" }, ] -provides-extras = ["redis", "postgresql", "kerberos", "dev"] +provides-extras = ["kerberos"] [package.metadata.requires-dev] dev = [ @@ -496,64 +432,95 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, ] +[[package]] +name = "cotton-icons" +version = "0.2.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "django-cotton" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/80/46/2b75ee203aac31785d9da42f89fdf4e06c7bd6fe5a6bfa76c66d0a6071eb/cotton_icons-0.2.0.tar.gz", hash = "sha256:59f5f9945a2e92ad2d9d7578357bba7dee07b1a8a8ed34a8a56e85bb10539f8b" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b9/26/52ef398c64754f2b930019b5e9d8f5d8170c2f21038d5b7507bcd5f1a669/cotton_icons-0.2.0-py3-none-any.whl", hash = "sha256:8a06a1b2acba534e08b018b02950c62221a7406cffcafc293ea4ccfa7efa45a0" }, +] + [[package]] name = "cryptography" -version = "46.0.7" +version = "46.0.3" source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de" }, - { url = "https://mirrors.aliyun.com/pypi/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83" }, - { url = "https://mirrors.aliyun.com/pypi/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85" }, - { url = "https://mirrors.aliyun.com/pypi/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842" }, - { url = "https://mirrors.aliyun.com/pypi/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902" }, - { url = "https://mirrors.aliyun.com/pypi/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99" }, - { url = "https://mirrors.aliyun.com/pypi/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee" }, - { url = "https://mirrors.aliyun.com/pypi/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298" }, - { url = "https://mirrors.aliyun.com/pypi/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067" }, - { url = "https://mirrors.aliyun.com/pypi/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba" }, - { url = "https://mirrors.aliyun.com/pypi/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85" }, - { url = "https://mirrors.aliyun.com/pypi/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968" }, - { url = "https://mirrors.aliyun.com/pypi/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4" }, +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91" }, + { url = "https://mirrors.aliyun.com/pypi/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217" }, + { url = "https://mirrors.aliyun.com/pypi/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665" }, + { url = "https://mirrors.aliyun.com/pypi/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df" }, + { url = "https://mirrors.aliyun.com/pypi/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d9/cd/1a8633802d766a0fa46f382a77e096d7e209e0817892929655fe0586ae32/cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4c/59/6b26512964ace6480c3e54681a9859c974172fb141c38df11eadd8416947/cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c" }, +] + +[[package]] +name = "cssbeautifier" +version = "1.15.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "editorconfig" }, + { name = "jsbeautifier" }, + { name = "six" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f7/01/fdf41c1e5f93d359681976ba10410a04b299d248e28ecce1d4e88588dde4/cssbeautifier-1.15.4.tar.gz", hash = "sha256:9bb08dc3f64c101a01677f128acf01905914cf406baf87434dcde05b74c0acf5" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/63/51/ef6c5628e46092f0a54c7cee69acc827adc6b6aab57b55d344fefbdf28f1/cssbeautifier-1.15.4-py3-none-any.whl", hash = "sha256:78c84d5e5378df7d08622bbd0477a1abdbd209680e95480bf22f12d5701efc98" }, ] [[package]] @@ -567,16 +534,16 @@ wheels = [ [[package]] name = "django" -version = "4.2.30" +version = "4.2.27" source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "asgiref" }, { name = "sqlparse" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/11/b5/f1a53dc68da6429d6e0345bb848161e2381a2e9f02700148911e8582c2b3/django-4.2.30.tar.gz", hash = "sha256:4ebc7a434e3819db6cf4b399fb5b3f536310a30e8486f08b66886840be84b37c" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ce/ff/6aa5a94b85837af893ca82227301ac6ddf4798afda86151fb2066d26ca0a/django-4.2.27.tar.gz", hash = "sha256:b865fbe0f4a3d1ee36594c5efa42b20db3c8bbb10dff0736face1c6e4bda5b92" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/39/b7/a7c96f239cf91313a6589233fed55111c7063b26683b226802732c455dbc/django-4.2.30-py3-none-any.whl", hash = "sha256:4d07aaf1c62f9984842b67c2874ebbf7056a17be253860299b93ae1881faad65" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/f5/1a2319cc090870bfe8c62ef5ad881a6b73b5f4ce7330c5cf2cb4f9536b12/django-4.2.27-py3-none-any.whl", hash = "sha256:f393a394053713e7d213984555c5b7d3caeee78b2ccb729888a0774dff6c11a8" }, ] [[package]] @@ -641,6 +608,15 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/2b/d2/9cb93cd1ef94ddc97c26c902ff75a859f5f154051fec98cf8242649b26ce/django_stubs_ext-6.0.2-py3-none-any.whl", hash = "sha256:b35bdec1995bf49765cc39fa89aa7c23f120a23d0cb0152ab7fb4e48ff7d667b" }, ] +[[package]] +name = "django-tianai-captcha" +version = "1.0.0" +source = { git = "https://github.com/trustedinster/django-tianai-captcha.git#c6ccc879afa6f95a3e63458953a33e68a7c4ec03" } +dependencies = [ + { name = "django" }, + { name = "pillow" }, +] + [[package]] name = "djangorestframework" version = "3.15.2" @@ -653,6 +629,53 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/7c/b6/fa99d8f05eff3a9310286ae84c4059b08c301ae4ab33ae32e46e8ef76491/djangorestframework-3.15.2-py3-none-any.whl", hash = "sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20" }, ] +[[package]] +name = "djlint" +version = "1.36.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama" }, + { name = "cssbeautifier" }, + { name = "jsbeautifier" }, + { name = "json5" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tqdm" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/74/89/ecf5be9f5c59a0c53bcaa29671742c5e269cc7d0e2622e3f65f41df251bf/djlint-1.36.4.tar.gz", hash = "sha256:17254f218b46fe5a714b224c85074c099bcb74e3b2e1f15c2ddc2cf415a408a1" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/53/71/6a3ce2b49a62e635b85dce30ccf3eb3a18fe79275d45535325a55a63d3a3/djlint-1.36.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a2dfb60883ceb92465201bfd392291a7597c6752baede6fbb6f1980cac8d6c5c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/72/47/308412dc579e277c910774f41b380308d582862b16763425583e69e0fc14/djlint-1.36.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4bc6a1320c0030244b530ac200642f883d3daa451a115920ef3d56d08b644292" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9b/6f/428dc044d1e34363265b1301dc9b53253007acd858879d54b369d233aa96/djlint-1.36.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3164a048c7bb0baf042387b1e33f9bbbf99d90d1337bb4c3d66eb0f96f5400a1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/13/0d488e551d73ddf369552fc6f4c7702ea683e4bc1305bcf5c1d198fbdace/djlint-1.36.4-cp310-cp310-win_amd64.whl", hash = "sha256:3196d5277da5934962d67ad6c33a948ba77a7b6eadf064648bef6ee5f216b03c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/68/18ecd1e4d54a523e1d077f01419d669116e5dede97f97f1eb8ddb918a872/djlint-1.36.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d68da0ed10ee9ca1e32e225cbb8e9b98bf7e6f8b48a8e4836117b6605b88cc7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1e/03/005cf5c66e57ca2d26249f8385bc64420b2a95fea81c5eb619c925199029/djlint-1.36.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c0478d5392247f1e6ee29220bbdbf7fb4e1bc0e7e83d291fda6fb926c1787ba7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9f/88/aea3c81343a273a87362f30442abc13351dc8ada0b10e51daa285b4dddac/djlint-1.36.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:962f7b83aee166e499eff916d631c6dde7f1447d7610785a60ed2a75a5763483" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/77/0f767ac0b72e9a664bb8c92b8940f21bc1b1e806e5bd727584d40a4ca551/djlint-1.36.4-cp311-cp311-win_amd64.whl", hash = "sha256:53cbc450aa425c832f09bc453b8a94a039d147b096740df54a3547fada77ed08" }, + { url = "https://mirrors.aliyun.com/pypi/packages/53/f5/9ae02b875604755d4d00cebf96b218b0faa3198edc630f56a139581aed87/djlint-1.36.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff9faffd7d43ac20467493fa71d5355b5b330a00ade1c4d1e859022f4195223b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/97/51/284443ff2f2a278f61d4ae6ae55eaf820ad9f0fd386d781cdfe91f4de495/djlint-1.36.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:79489e262b5ac23a8dfb7ca37f1eea979674cfc2d2644f7061d95bea12c38f7e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6d/5e/791f4c5571f3f168ad26fa3757af8f7a05c623fde1134a9c4de814ee33b7/djlint-1.36.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e58c5fa8c6477144a0be0a87273706a059e6dd0d6efae01146ae8c29cdfca675" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/11/894425add6f84deffcc6e373f2ce250f2f7b01aa58c7f230016ebe7a0085/djlint-1.36.4-cp312-cp312-win_amd64.whl", hash = "sha256:bb6903777bf3124f5efedcddf1f4716aef097a7ec4223fc0fa54b865829a6e08" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/83/88b4c885812921739f5529a29085c3762705154d41caf7eb9a8886a3380c/djlint-1.36.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ead475013bcac46095b1bbc8cf97ed2f06e83422335734363f8a76b4ba7e47c2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/32/38/67695f7a150b3d9d62fadb65242213d96024151570c3cf5d966effa68b0e/djlint-1.36.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6c601dfa68ea253311deb4a29a7362b7a64933bdfcfb5a06618f3e70ad1fa835" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/7a/cd851393291b12e7fe17cf5d4d8874b8ea133aebbe9235f5314aabc96a52/djlint-1.36.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bda5014f295002363381969864addeb2db13955f1b26e772657c3b273ed7809f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/31/56469120394b970d4f079a552fde21ed27702ca729595ab0ed459eb6d240/djlint-1.36.4-cp313-cp313-win_amd64.whl", hash = "sha256:16ce37e085afe5a30953b2bd87cbe34c37843d94c701fc68a2dda06c1e428ff4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/67/f7aeea9be6fb3bd984487af8d0d80225a0b1e5f6f7126e3332d349fb13fe/djlint-1.36.4-py3-none-any.whl", hash = "sha256:e9699b8ac3057a6ed04fb90835b89bee954ed1959c01541ce4f8f729c938afdd" }, +] + +[[package]] +name = "editorconfig" +version = "0.17.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/88/3a/a61d9a1f319a186b05d14df17daea42fcddea63c213bcd61a929fb3a6796/editorconfig-0.17.1.tar.gz", hash = "sha256:23c08b00e8e08cc3adcddb825251c497478df1dada6aefeb01e626ad37303745" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/96/fd/a40c621ff207f3ce8e484aa0fc8ba4eb6e3ecf52e15b42ba764b457a9550/editorconfig-0.17.1-py3-none-any.whl", hash = "sha256:1eda9c2c0db8c16dbd50111b710572a5e6de934e39772de1959d41f64fc17c82" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -679,60 +702,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e" }, ] -[[package]] -name = "greenlet" -version = "3.4.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/0c/bc/e30e1e3d5e8860b0e0ce4d2b16b2681b77fd13542fc0d72f7e3c22d16eff/greenlet-3.4.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:d18eae9a7fb0f499efcd146b8c9750a2e1f6e0e93b5a382b3481875354a430e6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5b/cc/e023ae1967d2a26737387cac083e99e47f65f58868bd155c4c80c01ec4e0/greenlet-3.4.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:636d2f95c309e35f650e421c23297d5011716be15d966e6328b367c9fc513a82" }, - { url = "https://mirrors.aliyun.com/pypi/packages/67/32/5be1677954b6d8810b33abe94e3eb88726311c58fa777dc97e390f7caf5a/greenlet-3.4.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:234582c20af9742583c3b2ddfbdbb58a756cfff803763ffaae1ac7990a9fac31" }, - { url = "https://mirrors.aliyun.com/pypi/packages/74/bf/2d58d5ea515704f83e34699128c9072a34bea27d2b6a556e102105fe62a5/greenlet-3.4.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:523677e69cd4711b5a014e37bc1fb3a29947c3e3a5bb6a527e1cc50312e5a398" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bd/69/6525049b6c179d8a923256304d8387b8bdd4acab1acf0407852463c6d514/greenlet-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b45e45fe47a19051a396abb22e19e7836a59ee6c5a90f3be427343c37908d65b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4e/6c/bbfb798b05fec736a0d24dc23e81b45bcee87f45a83cfb39db031853bddc/greenlet-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5434271357be07f3ad0936c312645853b7e689e679e29310e2de09a9ea6c3adf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b7/7d/981fe0e7c07bd9d5e7eb18decb8590a11e3955878291f7a7de2e9c668eb7/greenlet-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:a19093fbad824ed7c0f355b5ff4214bffda5f1a7f35f29b31fcaa240cc0135ab" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fb/c6/dba32cab7e3a625b011aa5647486e2d28423a48845a2998c126dd69c85e1/greenlet-3.4.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:805bebb4945094acbab757d34d6e1098be6de8966009ab9ca54f06ff492def58" }, - { url = "https://mirrors.aliyun.com/pypi/packages/54/f4/7cb5c2b1feb9a1f50e038be79980dfa969aa91979e5e3a18fdbcfad2c517/greenlet-3.4.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:439fc2f12b9b512d9dfa681c5afe5f6b3232c708d13e6f02c845e0d9f4c2d8c6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d6/af/b66ab0b2f9a4c5a867c136bf66d9599f34f21a1bcca26a2884a29c450bd9/greenlet-3.4.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a70ed1cb0295bee1df57b63bf7f46b4e56a5c93709eea769c1fec1bb23a95875" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e5/5c/8c5633ece6ba611d64bf2770219a98dd439921d6424e4e8cf16b0ac74ea5/greenlet-3.4.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c660bce1940a1acae5f51f0a064f1bc785d07ea16efcb4bc708090afc4d69e83" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a9/df/950d15bca0d90a0e7395eb777903060504cdb509b7b705631e8fb69ff415/greenlet-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee407d4d1ca9dc632265aee1c8732c4a2d60adff848057cdebfe5fe94eb2c8a2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1a/e7/0839afab829fcb7333c9ff6d80c040949510055d2d4d63251f0d1c7c804e/greenlet-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:956215d5e355fffa7c021d168728321fd4d31fd730ac609b1653b450f6a4bc71" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d9/2b/b4482401e9bcaf9f5c97f67ead38db89c19520ff6d0d6699979c6efcc200/greenlet-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:5cb614ace7c27571270354e9c9f696554d073f8aa9319079dcba466bbdead711" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0c/4d/d8123a4e0bcd583d5cfc8ddae0bbe29c67aab96711be331a7cc935a35966/greenlet-3.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:04403ac74fe295a361f650818de93be11b5038a78f49ccfb64d3b1be8fbf1267" }, - { url = "https://mirrors.aliyun.com/pypi/packages/65/8b/3669ad3b3f247a791b2b4aceb3aa5a31f5f6817bf547e4e1ff712338145a/greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/38/3e/3c0e19b82900873e2d8469b590a6c4b3dfd2b316d0591f1c26b38a4879a5/greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b5/33/99fef65e7754fc76a4ed14794074c38c9ed3394a5bd129d7f61b705f3168/greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996" }, - { url = "https://mirrors.aliyun.com/pypi/packages/36/f7/229f3aed6948faa20e0616a0b8568da22e365ede6a54d7d369058b128afd/greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/08/97/d988180011aa40135c46cd0d0cf01dd97f7162bae14139b4a3ef54889ba5/greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/0f/a5a26fe152fb3d12e6a474181f6e9848283504d0afd095f353d85726374b/greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08" }, - { url = "https://mirrors.aliyun.com/pypi/packages/42/cf/bb2c32d9a100e36ee9f6e38fad6b1e082b8184010cb06259b49e1266ca01/greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b7/47/6c41314bac56e71436ce551c7fbe3cc830ed857e6aa9708dbb9c65142eb6/greenlet-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7a/75/7e9cd1126a1e1f0cd67b0eda02e5221b28488d352684704a78ed505bd719/greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9d/c4/3e2df392e5cb199527c4d9dbcaa75c14edcc394b45040f0189f649631e3c/greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/da/af/750cdfda1d1bd30a6c28080245be8d0346e669a98fdbae7f4102aa95fff3/greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82" }, - { url = "https://mirrors.aliyun.com/pypi/packages/54/78/0cbc693622cd54ebe25207efbb3a0eb07c2639cb8594f6e3aaaa0bb077a8/greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ba/c0/8966767de01343c1ff47e8b855dc78e7d1a8ed2b7b9c83576a57e289f81d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b8/38/bcdc71ba05e9a5fda87f63ffc2abcd1f15693b659346df994a48c968003d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a1/c2/19b664b7173b9e4ef5f77e8cef9f14c20ec7fce7920dc1ccd7afd955d093/greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9b/96/795619651d39c7fbd809a522f881aa6f0ead504cc8201c3a5b789dfaef99/greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615" }, - { url = "https://mirrors.aliyun.com/pypi/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda" }, - { url = "https://mirrors.aliyun.com/pypi/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed" }, - { url = "https://mirrors.aliyun.com/pypi/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705" }, -] - [[package]] name = "gssapi" version = "1.11.1" @@ -756,6 +725,15 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/10/03/1b71feddb85f945101c3cdc07242805c5e9b48da546f8a922129ad8299e5/gssapi-1.11.1-cp314-cp314t-win_amd64.whl", hash = "sha256:da43c0e0ae84bb9f04c4e016eac6d3826c6357f827183042ba990ccedeeab052" }, ] +[[package]] +name = "heroicons" +version = "2.14.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5f/fe/ed8a483bbd518f421b891a3db4eb61c6f2a5f84886a92a80b82d0d6637b5/heroicons-2.14.0.tar.gz", hash = "sha256:e55ecc0a839cf872f55977d2dd04a1855bfdf080bed1d13db5264b0060e65a96" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/08/89/5e1511183a70edb8b10fed0507b76726fe060056c952a53a2484be96db24/heroicons-2.14.0-py3-none-any.whl", hash = "sha256:cdf7fa6de02b7bc5b4513d7f592a9a1faea150b050a3b4270a0dc445ebe930c9" }, +] + [[package]] name = "idna" version = "3.15" @@ -774,6 +752,28 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" }, ] +[[package]] +name = "jsbeautifier" +version = "1.15.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "editorconfig" }, + { name = "six" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ea/98/d6cadf4d5a1c03b2136837a435682418c29fdeb66be137128544cecc5b7a/jsbeautifier-1.15.4.tar.gz", hash = "sha256:5bb18d9efb9331d825735fbc5360ee8f1aac5e52780042803943aa7f854f7592" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/2d/14/1c65fccf8413d5f5c6e8425f84675169654395098000d8bddc4e9d3390e1/jsbeautifier-1.15.4-py3-none-any.whl", hash = "sha256:72f65de312a3f10900d7685557f84cb61a9733c50dcc27271a39f5b0051bf528" }, +] + +[[package]] +name = "json5" +version = "0.14.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9c/4b/6f8906aaf67d501e259b0adab4d312945bb7211e8b8d4dcc77c92320edaa/json5-0.14.0.tar.gz", hash = "sha256:b3f492fad9f6cdbced8b7d40b28b9b1c9701c5f561bef0d33b81c2ff433fefcb" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b8/42/cf027b4ac873b076189d935b135397675dac80cb29acb13e1ab86ad6c631/json5-0.14.0-py3-none-any.whl", hash = "sha256:56cf861bab076b1178eb8c92e1311d273a9b9acea2ccc82c276abf839ebaef3a" }, +] + [[package]] name = "kombu" version = "5.6.2" @@ -821,67 +821,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" }, ] -[[package]] -name = "msgpack" -version = "1.1.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/f5/a2/3b68a9e769db68668b25c6108444a35f9bd163bb848c0650d516761a59c0/msgpack-1.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0051fffef5a37ca2cd16978ae4f0aef92f164df86823871b5162812bebecd8e2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5b/e1/2b720cc341325c00be44e1ed59e7cfeae2678329fbf5aa68f5bda57fe728/msgpack-1.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a605409040f2da88676e9c9e5853b3449ba8011973616189ea5ee55ddbc5bc87" }, - { url = "https://mirrors.aliyun.com/pypi/packages/71/e5/c2241de64bfceac456b140737812a2ab310b10538a7b34a1d393b748e095/msgpack-1.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b696e83c9f1532b4af884045ba7f3aa741a63b2bc22617293a2c6a7c645f251" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b7/09/2a06956383c0fdebaef5aa9246e2356776f12ea6f2a44bd1368abf0e46c4/msgpack-1.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:365c0bbe981a27d8932da71af63ef86acc59ed5c01ad929e09a0b88c6294e28a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0e/74/2957703f0e1ef20637d6aead4fbb314330c26f39aa046b348c7edcf6ca6b/msgpack-1.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41d1a5d875680166d3ac5c38573896453bbbea7092936d2e107214daf43b1d4f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a5/09/3bfc12aa90f77b37322fc33e7a8a7c29ba7c8edeadfa27664451801b9860/msgpack-1.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:354e81bcdebaab427c3df4281187edc765d5d76bfb3a7c125af9da7a27e8458f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4b/4f/05fcebd3b4977cb3d840f7ef6b77c51f8582086de5e642f3fefee35c86fc/msgpack-1.1.2-cp310-cp310-win32.whl", hash = "sha256:e64c8d2f5e5d5fda7b842f55dec6133260ea8f53c4257d64494c534f306bf7a9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d0/3e/b4547e3a34210956382eed1c85935fff7e0f9b98be3106b3745d7dec9c5e/msgpack-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:db6192777d943bdaaafb6ba66d44bf65aa0e9c5616fa1d2da9bb08828c6b39aa" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296" }, - { url = "https://mirrors.aliyun.com/pypi/packages/da/e0/6cc2e852837cd6086fe7d8406af4294e66827a60a4cf60b86575a4a65ca8/msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef" }, - { url = "https://mirrors.aliyun.com/pypi/packages/25/98/6a19f030b3d2ea906696cedd1eb251708e50a5891d0978b012cb6107234c/msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b7/cd/9098fcb6adb32187a70b7ecaabf6339da50553351558f37600e53a4a2a23/msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e6/ae/270cecbcf36c1dc85ec086b33a51a4d7d08fc4f404bdbc15b582255d05ff/msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2a/79/309d0e637f6f37e83c711f547308b91af02b72d2326ddd860b966080ef29/msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68" }, - { url = "https://mirrors.aliyun.com/pypi/packages/73/4d/7c4e2b3d9b1106cd0aa6cb56cc57c6267f59fa8bfab7d91df5adc802c847/msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa" }, - { url = "https://mirrors.aliyun.com/pypi/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620" }, - { url = "https://mirrors.aliyun.com/pypi/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162" }, - { url = "https://mirrors.aliyun.com/pypi/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794" }, - { url = "https://mirrors.aliyun.com/pypi/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84" }, - { url = "https://mirrors.aliyun.com/pypi/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014" }, - { url = "https://mirrors.aliyun.com/pypi/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af" }, - { url = "https://mirrors.aliyun.com/pypi/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20" }, - { url = "https://mirrors.aliyun.com/pypi/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff" }, - { url = "https://mirrors.aliyun.com/pypi/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46" }, -] - [[package]] name = "mypy-extensions" version = "1.1.0" @@ -911,100 +850,100 @@ wheels = [ [[package]] name = "pillow" -version = "12.2.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024" }, - { url = "https://mirrors.aliyun.com/pypi/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab" }, - { url = "https://mirrors.aliyun.com/pypi/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65" }, - { url = "https://mirrors.aliyun.com/pypi/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808" }, - { url = "https://mirrors.aliyun.com/pypi/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe" }, - { url = "https://mirrors.aliyun.com/pypi/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421" }, - { url = "https://mirrors.aliyun.com/pypi/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005" }, - { url = "https://mirrors.aliyun.com/pypi/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795" }, - { url = "https://mirrors.aliyun.com/pypi/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed" }, - { url = "https://mirrors.aliyun.com/pypi/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795" }, - { url = "https://mirrors.aliyun.com/pypi/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea" }, - { url = "https://mirrors.aliyun.com/pypi/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98" }, - { url = "https://mirrors.aliyun.com/pypi/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295" }, - { url = "https://mirrors.aliyun.com/pypi/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae" }, - { url = "https://mirrors.aliyun.com/pypi/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463" }, - { url = "https://mirrors.aliyun.com/pypi/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06" }, - { url = "https://mirrors.aliyun.com/pypi/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e" }, +version = "12.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/fe/41/f73d92b6b883a579e79600d391f2e21cb0df767b2714ecbd2952315dfeef/pillow-12.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/55/7aca2891560188656e4a91ed9adba305e914a4496800da6b5c0a15f09edf/pillow-12.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/d2/b28221abaa7b4c40b7dba948f0f6a708bd7342c4d47ce342f0ea39643974/pillow-12.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/71/b8/7a61fb234df6a9b0b479f69e66901209d89ff72a435b49933f9122f94cac/pillow-12.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/51/55c751a57cc524a15a0e3db20e5cde517582359508d62305a627e77fd295/pillow-12.1.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/7c/60e3e6f5e5891a1a06b4c910f742ac862377a6fe842f7184df4a274ce7bf/pillow-12.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/06/37/49d47266ba50b00c27ba63a7c898f1bb41a29627ced8c09e25f19ebec0ff/pillow-12.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/e5/67fd87d2913902462cd9b79c6211c25bfe95fcf5783d06e1367d6d9a741f/pillow-12.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bd/15/f8c7abf82af68b29f50d77c227e7a1f87ce02fdc66ded9bf603bc3b41180/pillow-12.1.0-cp310-cp310-win32.whl", hash = "sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/24/7d1c0e160b6b5ac2605ef7d8be537e28753c0db5363d035948073f5513d7/pillow-12.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f4/03/41c038f0d7a06099254c60f618d0ec7be11e79620fc23b8e85e5b31d9a44/pillow-12.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/c4/bf8328039de6cc22182c3ef007a2abfbbdab153661c0a9aa78af8d706391/pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/06/7264c0597e676104cc22ca73ee48f752767cd4b1fe084662620b17e10120/pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/72/64/f9189e44474610daf83da31145fa56710b627b5c4c0b9c235e34058f6b31/pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ef/30/0df458009be6a4caca4ca2c52975e6275c387d4e5c95544e34138b41dc86/pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/86/95845d4eda4f4f9557e25381d70876aa213560243ac1a6d619c46caaedd9/pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/1f/8e66ab9be3aaf1435bc03edd1ebdf58ffcd17f7349c1d970cafe87af27d9/pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/f6/683b83cb9b1db1fb52b87951b1c0b99bdcfceaa75febf11406c19f82cb5e/pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9a/7d/de833d63622538c1d58ce5395e7c6cb7e7dce80decdd8bde4a484e095d9f/pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/40/50d86571c9e5868c42b81fe7da0c76ca26373f3b95a8dd675425f4a92ec1/pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/af/b1d7e301c4cd26cd45d4af884d9ee9b6fab893b0ad2450d4746d74a6968c/pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/36/d5716586d887fb2a810a4a61518a327a1e21c8b7134c89283af272efe84b/pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ad/26/7b82c0ab7ef40ebede7a97c72d473bda5950f609f8e0c77b04af574a0ddb/pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/25/27abc9792615b5e886ca9411ba6637b675f1b77af3104710ac7353fe5605/pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661" }, + { url = "https://mirrors.aliyun.com/pypi/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1d/23/c281182eb986b5d31f0a76d2a2c8cd41722d6fb8ed07521e802f9bba52de/pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/ef/7018273e0faac099d7b00982abdcc39142ae6f3bd9ceb06de09779c4a9d6/pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8f/c8/993d4b7ab2e341fe02ceef9576afcf5830cdec640be2ac5bee1820d693d4/pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea" }, + { url = "https://mirrors.aliyun.com/pypi/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796" }, + { url = "https://mirrors.aliyun.com/pypi/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13" }, + { url = "https://mirrors.aliyun.com/pypi/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/bc/224b1d98cffd7164b14707c91aac83c07b047fbd8f58eba4066a3e53746a/pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/ca/49ca7769c4550107de049ed85208240ba0f330b3f2e316f24534795702ce/pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72" }, + { url = "https://mirrors.aliyun.com/pypi/packages/73/48/fac807ce82e5955bcc2718642b94b1bd22a82a6d452aea31cbb678cddf12/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d2/95/3e0742fe358c4664aed4fd05d5f5373dcdad0b27af52aa0972568541e3f4/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5a/74/fe2ac378e4e202e56d50540d92e1ef4ff34ed687f3c60f6a121bcf99437e/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/77/2a60dee1adee4e2655ac328dd05c02a955c1cd683b9f1b82ec3feb44727c/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/71/64e9b1c7f04ae0027f788a248e6297d7fcc29571371fe7d45495a78172c0/pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19" }, ] [[package]] @@ -1037,69 +976,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955" }, ] -[[package]] -name = "psycopg2-binary" -version = "2.9.12" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/78/80/49bacf9e51617d8309f6f0123e29edc793f6f5f6700c7d1f1b20782fbb37/psycopg2_binary-2.9.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b818ceff717f98851a64bffd4c5eb5b3059ae280276dcecc52ac658dcf006a4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d3/f2/98eeac7d60c43df9338287834edf9b3e69be68a2db78a57b1b81d705e735/psycopg2_binary-2.9.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d2fa0d7caca8635c56e373055094eeda3208d901d55dd0ff5abc1d4e47f82b56" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9f/7c/30575e75f14d5351a56a1971bb43fe7f8bf7edf1b654fb1bec65c42a8812/psycopg2_binary-2.9.12-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:864c261b3690e1207d14bbfe0a61e27567981b80c47a778561e49f676f7ce433" }, - { url = "https://mirrors.aliyun.com/pypi/packages/65/9b/4df366d89f28c527dc39d0b6c98a5ca74e30d37ac097b73f3352147568ae/psycopg2_binary-2.9.12-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c5ee5213445dd45312459029b8c4c0a695461eb517b753d2582315bd07995f5e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4e/8a/c566803818eb03161ba869b6ba612bf7ad56816d98b9e5121e0a22ad6b0b/psycopg2_binary-2.9.12-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f9cae1f848779b5b01f417e762c40d026ea93eb0648249a604728cda991dde3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/63/fe/0dfa5797e0b229e0567bc378695224caf14d547f73b05be0c80549089772/psycopg2_binary-2.9.12-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:63a3ebbd543d3d1eda088ac99164e8c5bac15293ee91f20281fd17d050aee1c4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3c/89/28063adf17a4ba501eedd9890feab0c649ee4d8bd0a97df0ff1e9584feab/psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d6fcbba8c9fed08a73b8ac61ea79e4821e45b1e92bb466230c5e746bbf3d5256" }, - { url = "https://mirrors.aliyun.com/pypi/packages/84/94/5a01de0aa4ead0b8d8d1aa4ec18cec0bd36d03fa714eaa5bb8a0b1b50020/psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:36512911ebb2b60a0c3e44d0bb5048c1980aced91235d133b7874f3d1d93487c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7a/85/723bb085a61c6ac2dc0a0043f375f2fe7365363e27b073bad56ca5bda979/psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:8ffdb59fe88f99589e34354a130217aa1fd2d615612402d6edc8b3dbc7a44463" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b4/67/4d8b1e0d2fc4166677380eac0edf9cdff91013aca2546e8ef7bc04b56158/psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a46fe069b65255df410f856d842bc235f90e22ffdf532dda625fd4213d3fd9b1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/73/99/21af7a5498637ea4dc91a17c281a53bc1d632fbafe00f6689fbfb32a9fed/psycopg2_binary-2.9.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab29414b25dcb698bf26bf213e3348abdcd07bbd5de032a5bec15bd75b298b03" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d5/19/d4ce60954f3bb9d8e3bc5e5c4d1f2487de2d3851bf2391d54954c9df12a6/psycopg2_binary-2.9.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5c8ce6c61bd1b1f6b9c24ee32211599f6166af2c55abb19456090a21fd16554b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/53/71/c85409ee0d78890f0660eff262e815e7dd2bb741a17611d82e9e8cd9dc5e/psycopg2_binary-2.9.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b4a9eaa6e7f4ff91bec10aa3fb296878e75187bced5cc4bafe17dc40915e1326" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3c/ed/60486c2c7f0d4d1ede2bfb1ed27e2498477ce646bc7f6b2759906303117e/psycopg2_binary-2.9.12-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c6528cefc8e50fcc6f4a107e27a672058b36cc5736d665476aeb413ba88dbb06" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0b/b9/656cb03fad9f4f49f2145c334b1126ee75189929ca4e6187d485a2d59951/psycopg2_binary-2.9.12-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e4e184b1fb6072bf05388aa41c697e1b2d01b3473f107e7ec44f186a32cfd0b8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/99/66/08cf0da0e25cc6fb142c89be45fc8418792858f0c4cbff5e24530ff02cd6/psycopg2_binary-2.9.12-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4766ab678563054d3f1d064a4db19cc4b5f9e3a8d9018592a8285cf200c248f3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/17/d7/eecd9ce8e146d3721115d82d3836efdbb712187e4590325df549989d18f4/psycopg2_binary-2.9.12-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5a0253224780c978746cb9be55a946bcdaf40fe3519c0f622924cdabdafe2c39" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b6/2e/b1dc289b362cc8d45697b57eefbd673186f49a4ea0906928988e3affcc98/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0dc9228d47c46bda253d2ecd6bb93b56a9f2d7ad33b684a1fa3622bf74ffe30c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/eb/e4/4c4aea6473214dbdbd0fbba11aa4691e76dc01722c55724c5951719865ff/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f921f3cd87035ef7df233383011d7a53ea1d346224752c1385f1edfd790ceb6a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ba/5d/b03b99986446a4f57b170ed9a2579fb7ff9783ca0fa5226b19db99737fee/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d999bd982a723113c1a45b55a7a6a90d64d0ed2278020ed625c490ff7bef96c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/14/86/382ee4afbd1d97500c9d2862b20c2fdeddf4b7335e984df3fb4309f64108/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29d4d134bd0ab46ffb04e94aa3c5fa3ef582e9026609165e2f758ff76fc3a3be" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a8/16/9a57c75ba1eda7165c017342f526810d5f5a12647dde749c99ae9a7141d7/psycopg2_binary-2.9.12-cp311-cp311-win_amd64.whl", hash = "sha256:cb4a1dacdd48077150dc762a9e5ddbf32c256d66cb46f80839391aa458774936" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e2/9f/ef4ef3c8e15083df90ca35265cfd1a081a2f0cc07bb229c6314c6af817f4/psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b5/01/3dd14e46ba48c1e1a6ec58ee599fa1b5efa00c246d5046cd903d0eeb1af1/psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a6/f7/0640e4901119d8a9f7a1784b927f494e2198e213ceb593753d1f2c8b1b30/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b0/55/44df3965b5f297c50cc0b1b594a31c67d6127a9d133045b8a66611b14dfb/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b0/4b/74535248b1eac0c9336862e8617c765ac94dac76f9e25d7c4a79588c8907/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f2/ba/f1bf8d2ae71868ad800b661099086ee52bc0f8d9f05be1acd8ebb06757cc/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/45/46/c15706c338403b7c420bcc0c2905aad116cc064545686d8bf85f1999ea00/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b3/7c/a2d5dc09b64a4564db242a0fe418fde7d33f6f8259dd2c5b9d7def00fb5a/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c0/e8/cc8c9a4ce71461f9ec548d38cadc41dc184b34c73e6455450775a9334ccd/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/19/6a/31e2296bc0787c5ab75d3d118e40b239db8151b5192b90b77c72bc9256e9/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5f/a8/75f4e3e11203b590150abed2cf7794b9c9c9f7eceddae955191138b44dde/psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777" }, - { url = "https://mirrors.aliyun.com/pypi/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019" }, - { url = "https://mirrors.aliyun.com/pypi/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be" }, - { url = "https://mirrors.aliyun.com/pypi/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290" }, - { url = "https://mirrors.aliyun.com/pypi/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033" }, - { url = "https://mirrors.aliyun.com/pypi/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980" }, -] - [[package]] name = "pycodestyle" version = "2.14.0" @@ -1148,15 +1024,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c" }, ] -[[package]] -name = "pymysql" -version = "1.1.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f5/ae/1fe3fcd9f959efa0ebe200b8de88b5a5ce3e767e38c7ac32fb179f16a388/pymysql-1.1.2.tar.gz", hash = "sha256:4961d3e165614ae65014e361811a724e2044ad3ea3739de9903ae7c21f539f03" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl", hash = "sha256:e6b1d89711dd51f8f74b1631fe08f039e7d76cf67a42a323d3178f0f25762ed9" }, -] - [[package]] name = "pyotp" version = "2.9.0" @@ -1240,11 +1107,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.2" +version = "1.2.1" source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61" }, ] [[package]] @@ -1301,6 +1168,70 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/5c/1a/74bdbb7a3f8a6c1d2254c39c53c2d388529a314366130147d180522c59a3/pywinrm-0.4.3-py2.py3-none-any.whl", hash = "sha256:c476c1e20dd0875da6fe4684c4d8e242dd537025907c2f11e1990c5aee5c9526" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00" }, + { url = "https://mirrors.aliyun.com/pypi/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28" }, + { url = "https://mirrors.aliyun.com/pypi/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea" }, + { url = "https://mirrors.aliyun.com/pypi/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26" }, + { url = "https://mirrors.aliyun.com/pypi/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35" }, + { url = "https://mirrors.aliyun.com/pypi/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b" }, +] + [[package]] name = "redis" version = "7.4.0" @@ -1313,9 +1244,130 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec" }, ] +[[package]] +name = "regex" +version = "2026.5.9" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/fe/ed/0ad2c8edf634918eb4484365d3819fa7bd7f58daf807fe7fb21812c316e5/regex-2026.5.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a9e1328e17c84c1a5d22ec9f785ecef4a967fab9a42b6a8dc3bcbebd0a0c9e44" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/a9/4ed972ad263963b860b7c3e86e0e1bcc791def47b43b8c8efe57e710f139/regex-2026.5.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bfe1ce50cbfb569d74e1e4337da6468961f31dbea55fd85aa5de59c0947a805a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/81/075930d9fa28c4ea1f53398dd015ee7c882f623539759113cda1257f4b82/regex-2026.5.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15ee42209947f4ca045412eae98416317238163618ace2a8e54f99586a466733" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/c8/5cdfbf0b5dc6599e1b6131eff43262e5275d4ec3469ce10216061659aadb/regex-2026.5.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4bb445ff3f725f59df8f6014edb547ee928ec7023a774f6a39a3f953038cbb2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cd/ca/ae5fd6edc59b7f84b904b31d6ec39a860cbcecd10f64bd5a062ca83a4864/regex-2026.5.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:446ddd671e43ab535810c4b21cff7104945c701d4a14d1e6d1cd6f4e445a8bea" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f6/ce/a91cf555afb51f3b74a182e24ba073b91ea7bb64592fc4b315c111bb19fd/regex-2026.5.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b92817338591505f282cf3864c145244b1edcf5381d237038df955001091538" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/7f/725a0a2b245a4cf0c4bab29d0e97c74285d94136a65d1b55a6459a583502/regex-2026.5.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b8a143aca6c39b446ea8092cde25cc8fe9304d4f5fecfbc1a9dbb0282703c2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/2a/996efbd59ce6b5d4a09e3af6180ceb62af171f4a9a6fb557d2f0ae0d462b/regex-2026.5.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0f03aa6898aaaac4592479821df16e68e8d0e29e903e65d8f2dfb2f19028a989" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/0a/8731e8b8806174c9cdd5903f80a14990331c1f42fc4209b540952e9e010d/regex-2026.5.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ed457d8e98ae812ed7732bef7bf78de78e834eae0372a74e23ca90ef21d910f9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9a/0b/932473194bd563f342a412ae2ffbbd6da608306a2bc4e99249a41c2b0b92/regex-2026.5.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71b61c5bfe1c806332defc42ad6c780b3c55f661986d7f40283a3a88274b4c00" }, + { url = "https://mirrors.aliyun.com/pypi/packages/98/80/9523d196010031df25f7177ee0a467efbee436324038e5d99def17a57515/regex-2026.5.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:3b1e39888c5e0c7d92cea4fc777396c4a90363b05de75d02eb459a4752200808" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3c/07/56987b35e89edf47e4a38cf2845aeee476bfa688a6bdbd3e820cda461dc1/regex-2026.5.9-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6ba42b2e7e7f46cf68cc6a5ca36fa07959f9bbd9c6bdcc47b6ee76549a590248" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/2a/ff713fff0c566507c06a4ce2dc0ae8e7eeebc88811a95fc81cf1e7d534dd/regex-2026.5.9-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:c010eb8caca74bdb40c07498d7ece26b4428fd3f04aa8a72c9ac6f79e8faaac6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/90/df6d982b03e3614785c6937ba51b57f6733d97d2ee1c9bc7531dbfab3a54/regex-2026.5.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a6a563446a41adc451393dc6b8e6ad87979efaee3c8738690a8d1b08ebead1b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c7/8a/4e88a5f7c3e98489aac4dd23142723d907b2a595b4a6abcbacabefeded09/regex-2026.5.9-cp310-cp310-win32.whl", hash = "sha256:954cc214c04663ee6d266fc61739cad83054683048de65c5bd1d640ad28098ac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/40/4b224cb0582b2dca1786726e6cdabe26abbf757d7f6718332f186da155d2/regex-2026.5.9-cp310-cp310-win_amd64.whl", hash = "sha256:b310768746dd314ea6e2ff4cc89ef215426813396ff4e94ee8e6f7096c8b6e03" }, + { url = "https://mirrors.aliyun.com/pypi/packages/12/4d/014fbe803204cab0947ee428f09f658a29632053dde1d3c6176bb4f0fd4c/regex-2026.5.9-cp310-cp310-win_arm64.whl", hash = "sha256:19c16ceb4a267a8789e25733e583983eeab9f0f8664e66b0bd1c5d21f14c2d4b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/dc/c1f2df4027e82fc54b5a473e4b250f5139faca49a0fbe29a48668d228f34/regex-2026.5.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ccf5249114cc3e772ecdd88a98a86eca0fd74c61ce32a94743758c083fc05d48" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/d2/59f01110660081cce9c0bc30ebd0b5ee250dacf658e3248ed92f01e0e8ee/regex-2026.5.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46f1326ca6e65b0879d23ca302c0f2415aad42ff0309b9c818e7949fe19a41d8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/b6/14b2c84ff90ddb370c81d27503f4a0fcf071496416f4855f6cc8c5d81c35/regex-2026.5.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ef31cbfe458e21c6122ba8150ff060e0c7789ed0d26eb423f25472584920b555" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/d0/4db86529117320de0c84afd90e70bb47434625875e34fcef9d8c127c5b16/regex-2026.5.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:992604d02e6d9c6d786c24a706a71ecffe1020fc1ef264044474cd81fa2c3919" }, + { url = "https://mirrors.aliyun.com/pypi/packages/07/78/fe4800cd322f862ecffd2d553409b20d80650e5ed71b9d178f853d020b82/regex-2026.5.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9411dd64ca95477225734a93dfc8583b51916b8d5942f99d6cac21e09965451" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b5/d0/b3618a895dd8feb897c61bb2954edd265e1767d82a01d53065d5871127a3/regex-2026.5.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4a3ff360dfb836fecdb93a4598f9d6e2ac81e3e397125145c6221bf58cf4c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/33/6f/1481597e859ef19508b345eec4afd1416ed6e6b459c75a64026ef193aecf/regex-2026.5.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a661a7d270a61f7cf460caee8b9fa2d5ef9e5c681234bcb9e0fe14f488e7dfc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/73/59/955734c803f59108deccba3597ae440c76b62a652733c0006e6243758420/regex-2026.5.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f079e50a0d3cc3cd5091fa9ff45869a2e6b2cd35895731edafb0327901a8d86d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/68/8f/70c04a236d651c81881dac42ef8538bddda6121434509d0a22d9e601503b/regex-2026.5.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4ebe8f0b5ec5a5024dc4a4c59f444c4e9afc5f2abdbb8962065b75d27fb971f9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1d/96/05c7434d88185e5d27fe54aeb74df86bd77cd79f52f0b4eae54faa8fea70/regex-2026.5.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:97cf3bc1b7d7d2306772ec07366c80d9df00ff79e79cea32898883a646d2fae2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/c1/6e3d8202d981f3117004bf341ee74893ba4ba8a9fbaf4b94615846550a08/regex-2026.5.9-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0f9eede6a5cbdc02d4978090186390936e1776a7d1359b21e41014c609880bcf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/93/c7/e7737f1526b3fb32bd4c337fd6c71c3ebb5c8296fc34d11197e0955d2e35/regex-2026.5.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:01f0f5f55f4b64dacec85dc116d3c05fd23ad3ff037bbc73a2085775953c2611" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a5/27/0daffb1a535bb39f422c3d200f4ab023c71110ad66a32b366bee708baba0/regex-2026.5.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1268eddd8486dc561d08eee1156e40aa3a8fe10f4bdec8fa653b455fcbffd12c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/fc/294fe4fac4f2ed67207b17471815870c1c45b3a489e08e0ac96daea16ef6/regex-2026.5.9-cp311-cp311-win32.whl", hash = "sha256:8676474c07469d6f33dd1085ca2cd45f65785f32518f2b20e36d9953ca07f994" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/b0/8dce459f6245bcf8f6e9f23ac9569f1a0f15c131cc0745e82b43226204cf/regex-2026.5.9-cp311-cp311-win_amd64.whl", hash = "sha256:246de9d60aa3f8538b519834dd95cbf276ea263d6a7bd5a3666dc3fa0230505b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/db/8d/f9aeff6ad63a3ef720386f2907e6d34a35a510a6e498ebad28b0fb3f6ab6/regex-2026.5.9-cp311-cp311-win_arm64.whl", hash = "sha256:d726ca3f0d76969bf1e8e477d160d3d666bbf999f6860bd314889e5345782046" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/9b/6550044bc44e17c84d312c031c2ec42fbdb6a4ec4e29093be3a172d08772/regex-2026.5.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57eeeb05db7979413dec5438f2db21d7ecbba787cde7a711df1a6f6df672aa06" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1e/95/fc7ba4303b5a0f92446a12ee6778ef2c6c799233f5060042a31bf390cfe9/regex-2026.5.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:398c521292f4c7fb807001dcd54694d3a1fcafc179a36ad9cc56f98df85930b6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/4b/ee27938d1b2c443e89a9a10e00d2d19aa5ee300cd3d61140644e93bb083e/regex-2026.5.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7a7c26137296beba7784de6eba69c6a93a63ccebc385e4962fe67e267a91225" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/dd/ba103dc19614e25f3880800ca67ce093d6e21b325d72b8383c7bf906e9fa/regex-2026.5.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6441cc660d76107934a09c22167200839a0e89604a6297f78a974e66e931d2c0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cf/e7/f035b4fd858b050b0080bf302968dc0f59ba34e391872d54936758e6844e/regex-2026.5.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91328f1c23d47595ca3ef0a7557fa129c5a23404b775c770697d2f35b33e0107" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/51/8cd301ecc899aea28124357f729f4272f44de7806fc7ca02490bfbe253e8/regex-2026.5.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:93a7860539414dddaefba2b40f8771765ae17949d4c7182b876ce429e11a8309" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/1e/3fbe2fa1e8cebd62f3bb7d3321cff1640aca2e240b51d9bd624aad949260/regex-2026.5.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd2810d22146b6d838acc5ec15602cb6b47920aa4e33015df3868eedfd20bab8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/17/2f/6f6008682bf2cf98040a0d3153a8e557b6ab728d7713d045cee4ce544ab8/regex-2026.5.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daff2bdbaf1d23e52fdff7c0b7bc2048b68f978df6a4d107ac981f94caef2e66" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/2b/eee0d20a6842ba04df4b8847a920b57ef56853f14ef85405473e586b605a/regex-2026.5.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4eeb011098fcb77af513dcef521a3dbecbf8849b1e38940759d293b7a93f5026" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4a/98/6fc1e6410feefb92159edaed5041992bfe390e8d26c721865434acbca558/regex-2026.5.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ea9c8ecfa1b73c73b626534d6626e5340d429630943672b8480724f44e84b962" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/a3/bd855e0f2cb1a978ecf6fa6bb69632dd9c3f6ea3b81cde62fde14c9daec7/regex-2026.5.9-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cd2846168eb9ee3c513902bc8225409cb1caab31d04728b145171fa1625d9621" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/66/0ae8c092e60b14c79d24f8e0b7f0aea5bfbffdcab00b5483d13404d3c3a5/regex-2026.5.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39617fb0cde9c0e6306dc70e3bfc096f3da793219879f7ae7aa341a69fbdcf6d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/21/de/8dfde60fc1b21c946a893ba273403b72617edb261370cb1087099a83f088/regex-2026.5.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd03c4f0e33280d15cae17159b899245d6b7c53d21def19b263b39655061f5ce" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/1c/bdcc98f9a4af4fdd166c74941174619ccff4726d3ce32faa8e9a2ecd38dd/regex-2026.5.9-cp312-cp312-win32.whl", hash = "sha256:164eba9b755ea6f244b0d881196fbc1fac09714e9782c9e2732b813142033c8e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/87/240d36864f9e48ace85f72e79ced97ceb7f27ce87739a947dcb834b4e6bc/regex-2026.5.9-cp312-cp312-win_amd64.whl", hash = "sha256:86f40a5d6444db30a125c9c9177e6b25dad981cbc37451fd838f145e6edac92e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4f/b5/7b30f312b0669dff5beebe5b0989dc2d1a312b1a44fab852199c387a5b96/regex-2026.5.9-cp312-cp312-win_arm64.whl", hash = "sha256:96f5f58b54a063d7ea9dca08e1cf57bfe10499c4d579ee672da284f57f5f0070" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/da/797e91ecec6f84135da778ddce78c20e0af5d2a15c26f87a81bc3eadb6db/regex-2026.5.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d626b84406444b165fc0ba981604edea39f0588ff1f92baa23fe50799ea9afdb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/da/bf30abaaa737b58f4a4b8c4a03659e02fd92092c822e0197ed9e0daab917/regex-2026.5.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d7bdc0ab8f3dd7e1b4f9ab88634e13374669db86bb3c72e8292f07ae313f539f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/e7/d0eaf5713828417b9e5648cf81fa9bacd4961f6ab98c380c2034f8716e35/regex-2026.5.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a8820737949116ffff55fe18f9fc644530063ba6ebfcb8314239416e78f1347c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d3/9b/b3fdd62b003baa1a9b593cd8c8699c9651c2e80cc21a5c715707983c42d7/regex-2026.5.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0fbdbac82cb3e4450d0ccde7d7a35607f4cb2dd9fba4b8b69bfaf8c9fa6aed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/30/66ab84588765f5b4b271a9ca09ef7ce2b87caa95176ec3d2ad65d7bc4902/regex-2026.5.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57e8915c7986aa33d25e4d3629cef711cd2863f2961b10409f0c04cb8b7d9020" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/89/f05169e8588aac365f35ffc7f3bc3184f095ef4cfded7cfaa3c7fd5dbd89/regex-2026.5.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508f56a89ba9cb26e4168cbc37dbd60a28d82430a9e18ad1d25fe0883c314ca2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/e1/c93444052cf41581f3c884ab3fb5823daf0992f11cd4388d4275ca610558/regex-2026.5.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d189041f15691cfa2b6c4290448ec221244d225b3f5fe9e7771b34ffcdf6e2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/fe/0cf96b882f540e62e8b9956599798203d599c44cf4c77917ca27400ff69b/regex-2026.5.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e82db382b44d0111b22601c509c89f64434816c9e0eef9d1989cda8cc6ff1c04" }, + { url = "https://mirrors.aliyun.com/pypi/packages/23/5c/d78d4924e7fc875557b9e9b768423925fdfaac5549d06da7810019a9bd26/regex-2026.5.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2acfb48634f64996b57f90f39afa692ff362162722581921fe92239a59960f3c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/e0/5214774090e7b4524dcea3e3c4aa74141d43043f8beb49c1599db1c8b53a/regex-2026.5.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d29eebfc9525db68cad3c97eedd7f754fa265aa5cd0cf4f863b2421e1b48fc9f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6e/e1/4a57a83350319b1271f0d7a249b8672513ed928b237a741631270de6caea/regex-2026.5.9-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:debb893095e944091c16e641a6e33c1b0f4cb61ab945ec5afbf53ce7068834d8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/12/f4/499e74a20c156fc75836ee04a72a38d1a063978f600937f9760467beb1b0/regex-2026.5.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d659eee77986549c9ea45b861c7567e44d6287c3dc9a4565478853f7b9fe2ff6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5b/92/7eebc0d0a01e78629695f342ba17e0deaff8fb45e79cc0d7b98287da6e3e/regex-2026.5.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2efa205e6d98b24d1f3ab395c11aa15cdf10935bca283d0285e0499c284fba21" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/a4/018e71f7d2ad48c1ebe6d3ae0026f9b7cb4802fd15c7cc02fdf724355102/regex-2026.5.9-cp313-cp313-win32.whl", hash = "sha256:f3844f134e834076677dd369976e9f5068679fcb8e50102fdf6b7ac96a3ec127" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e6/1d/861a93719fb9ee7dbfc3761b3797b7a3e112a5d42c6129459d2d741be9b5/regex-2026.5.9-cp313-cp313-win_amd64.whl", hash = "sha256:3527bb4942d2c14552155406cdedd906567456821848aed1cb4933a391bf5eca" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d9/c6/0a2436ae4da1ba76e51cb98943c6838a9a721faa40ebe2dce07694ae34e3/regex-2026.5.9-cp313-cp313-win_arm64.whl", hash = "sha256:56a33f191f17d8c417f99945ebdc1e691d3af9605d86ec68c7e54a57e3e17af6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e8/e9/d21346f7b60ed58789371358ed66b09d00f832e1bd7c06e55d9da5679882/regex-2026.5.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:01f28d868834624c934b8d2e0aa1c8341337e37831f4a012f18a5afcba4cbaf3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/43/fd1177a2032037c681baecdb3422ee4e1424aec4e4f470ef47793d325274/regex-2026.5.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:48036f6374aaa79eb3b754ec29c61d1c6b1606749d705a13f8854fa2539671f6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/7d/9fbf919768368d3f8a4f6c692cf2aa61e482b2b81ec6a298ace4cbf02480/regex-2026.5.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b96350aa424e79d4fd6b567b344dcbe2b2d6bfc48dfe7717587e1fa6d43da6ff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/6c/e41bfeecb589716843e7c4df09ba46ff2a42961457afece19059d85caeef/regex-2026.5.9-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f3af7a4903c5c04a11a196a5aa75cdd7dd3f8508132f9fb3259d9f5908e3b88" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/83/a5c1c525fba0aa656e88ad0face0b1829788ef4c2fb6b26df58aa1151b84/regex-2026.5.9-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e87577720152d2caae19fe2baaf1f8d5ca12091e9e229f03915c37d1e4b9178" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/d4/80882e799e440dd878b0979cbebf8fa4d54624a332c83037c7a701649e3f/regex-2026.5.9-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c8b9b9d294cfea3cd19c718ade7cc93492b2c4991abd9a68d0b3477ae6d8e100" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ae/ff/8db60211e2286e396aad7dc7725356c502bff0901ea05bd6cdc2e1a042b9/regex-2026.5.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:728d8bfd28a8845c8b6bc5dc7ce010453d206396786c0765c2740cb65f37791e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4c/47/742ef579c61730f8d268e5cf1f9ce0e37e2ea041ad0f5644724f2378e463/regex-2026.5.9-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e30b874d341fac767d7df5a0870540541c2c054b80cfaac116e8d367a8a7ff2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7f/ab/cb0999802dcb0fb95b1ab005e8d4163d8afdd67efc2cb6b6630ac13f8cb1/regex-2026.5.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fd190e88a895a8901325fad284a3f74ea52b1da8525b76cc811fa9b1edf0ce2b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7d/62/8ca59a24c55bc34d166eefaf3717bd77772f329fdbf984d86581e0a3571c/regex-2026.5.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:8e76e8161ad00694cfce6767d5dea860c6391ac5b83e5c3a39661e696f11fc7e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8d/3d/30f2ae62cef3278bb5bb821f467277a55fb73f01032cf85997e15e8289a8/regex-2026.5.9-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ddda5340e6c01a293027dd46232fa79eaff1b48058ce7a98f572b6445b088041" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/ae/7d2089bcd78ad0c0161bc684339df50032acb438a7bd3305e7ddb1193cec/regex-2026.5.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:205109e96b3cf5adf8f4cd62bedde9487feb282b9497a3535451e5a24cd706a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a9/29/92ff47f75990131ea4f24ba17819e5a9d141e10819807e09addd73409af6/regex-2026.5.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbe4579b9f08036aa7d101d1835437a20783574ac66327e6b29b4018a138081" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/99/eff29f1037dcab36702c9ee5d6858cf1ce2336ea8ea2987f64245b99ea5e/regex-2026.5.9-cp313-cp313t-win32.whl", hash = "sha256:ed2c9e8068b614c574d8d30e543d617cf5379b0535d46f97ef00e904745a08b5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0e/9d/8870b8981d27b22cda77bb26a5ac7ebfa9c7d9e0dea195a834a82380e748/regex-2026.5.9-cp313-cp313t-win_amd64.whl", hash = "sha256:b46b0f094dc1d3b90356c85a0bd2c9bafc4a6a190b9d6f8ddd5a033b6e088ed4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/72/b1/3379415e8f135c13ac551353397cc4fe97b4978f3cac73c5fcbcded548b8/regex-2026.5.9-cp313-cp313t-win_arm64.whl", hash = "sha256:872acc074bd29ffc9913ecdfedf6ea77502312ca44a4aa0d3779089c6069d8de" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/3e/9c3cd292d8808b3645a2ce517e200179b6d0e903f176300bd8b542e14de5/regex-2026.5.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:1bd7587a2948b4085195d5a3374eaf4a425dc3e55784c038175355ecf3bbbf8a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/70/d43ee8a2ca0a8b68d167f21658b85520ac0574617c7f320367c5047f7556/regex-2026.5.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dea2e88e1cce4522496cce630e11e67b98b7076620bc4336c3f674bc21a375f4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/21/91/9d50b433828d8e74196904e168a43abf1e6e88b2a15d47ed742456720c37/regex-2026.5.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2099f7e7ff7b6aa3192312650a56e91cc091e49d50b04e4f6f8b6e28b3b27f1c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/d2/b835e3cafbb9d977736912436259ff551d60919f7d7b3d37d46659c63564/regex-2026.5.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd353045824e4477562a2ac718c25799cdaaa41f7aa925a806a8a3e6848a5b9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/a6/9f992d00019166b9de01c546dd4549bc679f2a68df11b877740b0760b7c2/regex-2026.5.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65c8c8c37377794bd5b2f3ebe51919042bf17aec802e23c833d89782ed0c78af" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/08/4d32af657e049b19cb62b02e46e38fe1518797bfb2203ee93a510b21b0dc/regex-2026.5.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b73ab8afcf66c622db143d1c6fda4e58e4d537ee4f125229ad47b1ab80f34c0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d9/27/2af43dd1dc201d1fecefda64a45f4ad0995855b92724f795a777b402ee69/regex-2026.5.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0de5cf193997384ed2ca6f1cd4f78055b255d93d82d5a8cd6ba0d11c10b167e4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/dd/23a249047013b5321d4a60c4d2437462086f601b061776a525e5fba2a59f/regex-2026.5.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d641a8c9a61618047796d572a39a79b26167b0411d2c3031937b2fe2d081e2cf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/6a/e85ed9538cd19586d0465076a4578a12e093ce776d15f3f8ce92733a8dd6/regex-2026.5.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24b2355ef5cc9aa5b8f07d17704face1c166fdcc2290fa7bd6e6c925655a8346" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/c4/f25473209438638e947c55f9156fd8f236f74169229028cc99116380868e/regex-2026.5.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a24852d3c29ad9e47593593d8a247c44ccc3d0548ef12c822d6ed0810affe676" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/f7/f4f86e3c74419c37370e91f150ae0c2ef7d34b2e0e4cdd5da046a02e4022/regex-2026.5.9-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:916714069da19329ef7de197dcbc77bb3104145c7c2c864dbfbe318f46b88b14" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/70/704d8e13765939146b1cd0ef4e2feb71d7929727d2290f026eed10095955/regex-2026.5.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fa411799ca8da32a8d38d020a88faa5b6f91657d284761352940ecf9f7c3bbdd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/29/1a13582a8460038edc38e49f64ceb0dd7c60f5caba77571f4bf6601965d9/regex-2026.5.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e6da47d679b7010ef27556b6e0f99771b744936db1792a10ceac6547ae1503e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/73/56/3dcafe34fc72e271d62ad9a291801e88a1457bb251c132f15fcc2e5aad1a/regex-2026.5.9-cp314-cp314-win32.whl", hash = "sha256:98bd73080e8756255137e1bd3f3f00295bbc5aa383c0e0f973920e9134d7c4ad" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/9c/02eebf0be95efe416c664db7fb8b6b05b7a0b06a7544f2884f2558b0526f/regex-2026.5.9-cp314-cp314-win_amd64.whl", hash = "sha256:ff8d372ac2acdc048d1c19916f27ee61bc5722728458ba6ca5052f2c72d51763" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/5a/1dd1abee76cb7a846a0bcf42fdc87e5720c3c33c24f3e37814310a513d9f/regex-2026.5.9-cp314-cp314-win_arm64.whl", hash = "sha256:e1d93bf647916292e8edcec150c07ddf3dc50179ccaf770c04a7f9e452155372" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/c1/c5f619b0057a7965cb78ec559c1d7a45ce8c99a35bea95483d64959a93d9/regex-2026.5.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:83d0ee4a57d1c87cb549e195ec300b8f0ec3a82eba66d835e4e2ed8634fe4499" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/2c/5d01f1aee33de4bbe60c8452945bfc8477ca7c5ae4450f6bfe711036cb36/regex-2026.5.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d3d7eb5c9a7f6df82ed3cfac9beb93882a5cbcb5b8b157b56cb2b3b276574ac1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7a/fe/e8988b2ae2108c6ef71bd4aa8d87fbe257976dd0810e826cd75f701c68b6/regex-2026.5.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:075160bf16658e16d35233300b8453aac25de4cbea808d22348b6979668e924d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/79/34/d2b0937faa7859263f7f0a3c6b103a1296306be6952dc173d0154e9a2f49/regex-2026.5.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45375819235558a4ff1c4971dc32881f022613abdb180128f5cb4768c1765a1c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/fe/daf53a47457a8486db66c66c01ceb9c2303eecee3f87197f1e77eb1a736d/regex-2026.5.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ead4b163ac30a29574510cd4b3e2e985ac5290c05fc7095557d6a5f403fc31b5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/75/058fc4470cbfbf57d800aff1a0022b929a3f9fa553ee10a0cdf2070eb31f/regex-2026.5.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c6e4218fbdfbcd4f6c19efca40930d24a621bf4b48cb76bc6640543bd28ef20" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/e7/179cfda3a28bc843b5c6cfe7f79f23489c791ed95f151083803660878432/regex-2026.5.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6351571c8a42b505eb555c0dc47d740d0fb66977dc142919eea6f4325b7c56a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/90/6f0cc422071688266d344fca8462d787cba0a2c144acb25721f9a61ec265/regex-2026.5.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:002205cafd2a9e78c6290c7d1df277bf3277b3b7a30e0b4bb0dac2e2e3f7cb2d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/02/67/a31f1760f09c27b251ef39e9beb541f462cf977381d067faa764c2c0e393/regex-2026.5.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8abd33fef90b2a9efac5557d6033ca82d1195ed3a15fea5af15ba7b463c6a63b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/c4/1a80654597b6bc1e1ea0494824c31200e8a956abe290afae9b19a166a148/regex-2026.5.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:31037c82eccb44b7ea2e9e221d7c01429430e989a1f4b91ea5a855f6017b509a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/11/960724e06482c08466ff5611e242e86f80062949cdf6b4b9cc317b9dd93d/regex-2026.5.9-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5604dfd046dc37eca90250fc3be938b076c8059fa772ac0ed6f499b0f0fb0415" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/a8/a9979c3e7918280e93159ebcab5ef1a65116dd4f3bd6091be0eae4a126e8/regex-2026.5.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e1b1b4e496afbb24f4a62aba855ee4f88f25578927697b340702e48c9ee6bc2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fe/d4/a9b732f2f0072c0ab12227483abb24fffcb9f73f8a2b203df0a6d0434735/regex-2026.5.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be3372b9df6ddecff6486d37e19095a7b4973137caf5512407a89f4455361f41" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/fe/1b3113817447a1d4155e4ac76d2e072f42c0bcba2f43fa8a0e756ea2cd91/regex-2026.5.9-cp314-cp314t-win32.whl", hash = "sha256:3ddd90103f9e5c471c49c7852ecc1fe27c7e45eb99e977aefe7caa4e779f4f58" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/73/93d42045302636c91f2e5ef588b65b84b01428f28ec77de256b1dfdfbe5c/regex-2026.5.9-cp314-cp314t-win_amd64.whl", hash = "sha256:ca518ed29c46eecba6010b15f1b9a479314d2de409536e71b6a13aa04e3b8a77" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/80/35b4c33c804a165a7f55289afda3ea9e3eb6d15800341a2d66455c0f1f30/regex-2026.5.9-cp314-cp314t-win_arm64.whl", hash = "sha256:5e41809d2683fcde7d5a8c87a6567ba1fb1ce0de9f31bff578de00a4b2d76daa" }, +] + [[package]] name = "requests" -version = "2.33.0" +version = "2.32.3" source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "certifi" }, @@ -1323,9 +1375,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" }, ] [[package]] @@ -1351,66 +1403,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274" }, ] -[[package]] -name = "sqlalchemy" -version = "2.0.49" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/96/76/f908955139842c362aa877848f42f9249642d5b69e06cee9eae5111da1bd/sqlalchemy-2.0.49-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:42e8804962f9e6f4be2cbaedc0c3718f08f60a16910fa3d86da5a1e3b1bfe60f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/e2/17ba0b7bfbd8de67196889b6d951de269e8a46057d92baca162889beb16d/sqlalchemy-2.0.49-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc992c6ed024c8c3c592c5fc9846a03dd68a425674900c70122c77ea16c5fb0b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/90/1e/410dd499c039deacff395eec01a9da057125fcd0c97e3badc252c6a2d6a7/sqlalchemy-2.0.49-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eb188b84269f357669b62cb576b5b918de10fb7c728a005fa0ebb0b758adce1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ab/06/e797a8b98a3993ac4bc785309b9b6d005457fc70238ee6cefa7c8867a92e/sqlalchemy-2.0.49-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:62557958002b69699bdb7f5137c6714ca1133f045f97b3903964f47db97ea339" }, - { url = "https://mirrors.aliyun.com/pypi/packages/44/d3/5a9f7ef580af1031184b38235da6ac58c3b571df01c9ec061c44b2b0c5a6/sqlalchemy-2.0.49-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:da9b91bca419dc9b9267ffadde24eae9b1a6bffcd09d0a207e5e3af99a03ce0d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/69/ec/7be8c8cb35f038e963a203e4fe5a028989167cc7299927b7cf297c271e37/sqlalchemy-2.0.49-cp310-cp310-win32.whl", hash = "sha256:5e61abbec255be7b122aa461021daa7c3f310f3e743411a67079f9b3cc91ece3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b5/31/0defb93e3a10b0cf7d1271aedd87251a08c3a597ee4f353281769b547b5a/sqlalchemy-2.0.49-cp310-cp310-win_amd64.whl", hash = "sha256:0c98c59075b890df8abfcc6ad632879540f5791c68baebacb4f833713b510e75" }, - { url = "https://mirrors.aliyun.com/pypi/packages/60/b5/e3617cc67420f8f403efebd7b043128f94775e57e5b84e7255203390ceae/sqlalchemy-2.0.49-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe" }, - { url = "https://mirrors.aliyun.com/pypi/packages/20/9b/91ca80403b17cd389622a642699e5f6564096b698e7cdcbcbb6409898bc4/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b1/61/0722511d98c54de95acb327824cb759e8653789af2b1944ab1cc69d32565/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536" }, - { url = "https://mirrors.aliyun.com/pypi/packages/46/55/d514a653ffeb4cebf4b54c47bec32ee28ad89d39fafba16eeed1d81dccd5/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2f/16/0dcc56cb6d3335c1671a2258f5d2cb8267c9a2260e27fde53cbfb1b3540a/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700" }, - { url = "https://mirrors.aliyun.com/pypi/packages/51/6c/f8ab6fb04470a133cd80608db40aa292e6bae5f162c3a3d4ab19544a67af/sqlalchemy-2.0.49-cp311-cp311-win32.whl", hash = "sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c4/59/55a6d627d04b6ebb290693681d7683c7da001eddf90b60cfcc41ee907978/sqlalchemy-2.0.49-cp311-cp311-win_amd64.whl", hash = "sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af" }, - { url = "https://mirrors.aliyun.com/pypi/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f8/2f/6fd118563572a7fe475925742eb6b3443b2250e346a0cc27d8d408e73773/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c5/d7/410f4a007c65275b9cf82354adb4bb8ba587b176d0a6ee99caa16fe638f8/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d9/95/81f594aa60ded13273a844539041ccf1e66c5a7bed0a8e27810a3b52d522/sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/88/50/a6af0ff9dc954b43a65ca9b5367334e45d99684c90a3d3413fc19a02d43c/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bc/d1/5f6bdad8de0bf546fc74370939621396515e0cdb9067402d6ba1b8afbe9a/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f7/30/ad62227b4a9819a5e1c6abff77c0f614fa7c9326e5a3bdbee90f7139382b/sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/17/3a/7215b1b7d6d49dc9a87211be44562077f5f04f9bb5a59552c1c8e2d98173/sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148" }, - { url = "https://mirrors.aliyun.com/pypi/packages/28/4b/52a0cb2687a9cd1648252bb257be5a1ba2c2ded20ba695c65756a55a15a4/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8c/d8/fda95459204877eed0458550d6c7c64c98cc50c2d8d618026737de9ed41a/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ff/0a/2aac8b78ac6487240cf7afef8f203ca783e8796002dc0cf65c4ee99ff8bb/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a5/3d/ce71cfa82c50a373fd2148b3c870be05027155ce791dc9a5dcf439790b8b/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d5/e8/0a9f5c1f7c6f9ca480319bf57c2d7423f08d31445974167a27d14483c948/sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0e/51/fb5240729fbec73006e137c4f7a7918ffd583ab08921e6ff81a999d6517a/sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba" }, - { url = "https://mirrors.aliyun.com/pypi/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066" }, - { url = "https://mirrors.aliyun.com/pypi/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187" }, - { url = "https://mirrors.aliyun.com/pypi/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401" }, - { url = "https://mirrors.aliyun.com/pypi/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01" }, - { url = "https://mirrors.aliyun.com/pypi/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158" }, - { url = "https://mirrors.aliyun.com/pypi/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0" }, -] - [[package]] name = "sqlparse" version = "0.5.4" @@ -1500,6 +1492,18 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe" }, ] +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf" }, +] + [[package]] name = "types-pyyaml" version = "6.0.12.20250915" @@ -1554,6 +1558,15 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/72/c6/1452e716c5af065c018f75d42ca97517a04ac6aae4133722e0424649a07c/wcwidth-0.3.2-py3-none-any.whl", hash = "sha256:817abc6a89e47242a349b5d100cbd244301690d6d8d2ec6335f26fe6640a6315" }, ] +[[package]] +name = "whitenoise" +version = "6.12.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2" }, +] + [[package]] name = "xmltodict" version = "1.0.2"