From 1a1c2675d193500c2ea6b5f558b2c5241a893e83 Mon Sep 17 00:00:00 2001 From: shoucandanghehe Date: Sat, 3 Aug 2024 23:52:45 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20=E5=86=8D=E6=AC=A1=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=A8=A1=E6=9D=BF=E4=BB=93=E5=BA=93=E5=A4=84=E7=90=86?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nonebot_plugin_tetris_stats/utils/host.py | 4 +- .../utils/render/__init__.py | 4 +- .../utils/templates.py | 264 +++++++----------- 3 files changed, 108 insertions(+), 164 deletions(-) diff --git a/nonebot_plugin_tetris_stats/utils/host.py b/nonebot_plugin_tetris_stats/utils/host.py index 6e40b4b..877d8bb 100644 --- a/nonebot_plugin_tetris_stats/utils/host.py +++ b/nonebot_plugin_tetris_stats/utils/host.py @@ -11,7 +11,7 @@ from nonebot.log import logger from ..config.config import CACHE_PATH from .image import img_to_png from .request import Request -from .templates import templates_dir +from .templates import TEMPLATES_DIR if TYPE_CHECKING: from pydantic import IPvAnyAddress @@ -48,7 +48,7 @@ class HostPage: def _(): app.mount( '/host/assets', - StaticFiles(directory=templates_dir / 'assets'), + StaticFiles(directory=TEMPLATES_DIR / 'assets'), name='assets', ) logger.success('assets mounted') diff --git a/nonebot_plugin_tetris_stats/utils/render/__init__.py b/nonebot_plugin_tetris_stats/utils/render/__init__.py index a7b236e..81ba164 100644 --- a/nonebot_plugin_tetris_stats/utils/render/__init__.py +++ b/nonebot_plugin_tetris_stats/utils/render/__init__.py @@ -3,7 +3,7 @@ from typing import Literal, overload from jinja2 import Environment, FileSystemLoader from nonebot.compat import PYDANTIC_V2 -from ..templates import templates_dir +from ..templates import TEMPLATES_DIR from .schemas.bind import Bind from .schemas.tetrio.tetrio_info import Info as TETRIOInfo from .schemas.tetrio.tetrio_rank_detail import Data as TETRIORankDetailData @@ -17,7 +17,7 @@ from .schemas.top_info import Info as TOPInfo from .schemas.tos_info import Info as TOSInfo env = Environment( - loader=FileSystemLoader(templates_dir), autoescape=True, trim_blocks=True, lstrip_blocks=True, enable_async=True + loader=FileSystemLoader(TEMPLATES_DIR), autoescape=True, trim_blocks=True, lstrip_blocks=True, enable_async=True ) diff --git a/nonebot_plugin_tetris_stats/utils/templates.py b/nonebot_plugin_tetris_stats/utils/templates.py index 4ba6443..0a4d6cf 100644 --- a/nonebot_plugin_tetris_stats/utils/templates.py +++ b/nonebot_plugin_tetris_stats/utils/templates.py @@ -1,186 +1,130 @@ -from asyncio.subprocess import PIPE, Process, create_subprocess_exec -from enum import Enum, auto +from hashlib import sha256 +from http import HTTPStatus from pathlib import Path from shutil import rmtree -from typing import NamedTuple +from time import time_ns +from zipfile import ZipFile +from aiofiles import open +from httpx import AsyncClient from nonebot import get_driver from nonebot.log import logger from nonebot.permission import SUPERUSER from nonebot_plugin_alconna import Alconna, Args, Option, on_alconna -from nonebot_plugin_alconna.uniseg import UniMessage -from nonebot_plugin_localstore import get_data_dir # type: ignore[import-untyped] +from nonebot_plugin_localstore import get_cache_file, get_data_dir +from rich.progress import Progress driver = get_driver() -templates_dir = get_data_dir('nonebot_plugin_tetris_stats') / 'templates' +TEMPLATES_DIR = get_data_dir('nonebot_plugin_tetris_stats') / 'templates' alc = on_alconna(Alconna('更新模板', Option('--revision', Args['revision', str], alias={'-R'})), permission=SUPERUSER) -logger.level('GIT', no=10, color='') + +async def download_templates(tag: str) -> Path: + logger.info(f'开始下载模板 {tag}') + async with AsyncClient() as client: + if tag == 'latest': + logger.info('目标为 latest, 正在获取最新版本号') + tag = ( + ( + await client.get( + 'https://github.com/A-Minos/tetris-stats-templates/releases/latest', follow_redirects=True + ) + ) + .url.path.strip('/') + .rsplit('/', 1)[-1] + ) + logger.success(f'获取到的最新版本号: {tag}') + path = get_cache_file('nonebot_plugin_tetris_stats', f'dist_{time_ns()}.zip') + with Progress() as progress: + task_id = progress.add_task('[red]Downloading...', total=None) + async with ( + client.stream( + 'GET', + f'https://github.com/A-Minos/tetris-stats-templates/releases/download/{tag}/dist.zip', + follow_redirects=True, + ) as response, + open(path, 'wb') as file, + ): + response.raise_for_status() + progress.update(task_id, total=int(response.headers.get('content-length', 0)) or None) + async for chunk in response.aiter_bytes(): + await file.write(chunk) + progress.update(task_id, advance=len(chunk)) + logger.success('模板下载完成') + return path -class Status(Enum): - OK = auto() - NOT_EXIST = auto() - NOT_INITIALIZATION = auto() +async def unzip_templates(zip_path: Path) -> Path: + logger.info('开始解压模板') + temp_path = TEMPLATES_DIR.parent / f'temp_{time_ns()}' + with ZipFile(zip_path) as zip_file: + zip_file.extractall(temp_path) + zip_path.unlink() + logger.success('模板解压完成') + return temp_path -class Output(NamedTuple): - stdout: list[str] - stderr: list[str] - - -async def parse_log(proc: Process) -> Output: - stdout, stderr = await proc.communicate() - for i in (out := stdout.decode().splitlines()): - logger.log('GIT', f'stdout: {i}') - # stderr 可能是 None - for i in (err := (stderr or b'').decode().splitlines()): - logger.log('GIT', f'stderr: {i}') - return Output(out, err) - - -async def check_git() -> None: - try: - await parse_log(await create_subprocess_exec('git', '--version', stdout=PIPE)) - except FileNotFoundError as e: - msg = '未找到 git, 请确保 git 已安装并在环境变量中\n安装步骤请参阅: https://git-scm.com/book/zh/v2/%E8%B5%B7%E6%AD%A5-%E5%AE%89%E8%A3%85-Git' - raise RuntimeError(msg) from e - - -async def check_repo(repo_path: Path) -> Status: - if not repo_path.exists(): - return Status.NOT_EXIST - proc = await create_subprocess_exec( - 'git', 'rev-parse', '--is-inside-work-tree', stdout=PIPE, stderr=PIPE, cwd=repo_path - ) - await parse_log(proc) - if proc.returncode != 0: - return Status.NOT_INITIALIZATION - return Status.OK - - -async def clone_repo(repo_url: str, repo_path: Path, branch: str | None = None, depth: int | None = 1) -> bool: - args: list[str | Path] = ['git', 'clone', repo_url, repo_path] - if branch is not None: - args.extend(['-b', branch]) - if depth is not None: - args.append(f'--depth={depth}') - proc = await create_subprocess_exec(*args, stdout=PIPE, stderr=PIPE) - await parse_log(proc) - return proc.returncode == 0 - - -async def checkout(revision: str, repo_path: Path) -> bool: - proc = await create_subprocess_exec('git', 'checkout', revision, stdout=PIPE, stderr=PIPE, cwd=repo_path) - await parse_log(proc) - return proc.returncode == 0 - - -async def init_templates() -> None: - await check_git() - status = await check_repo(templates_dir) - if status == Status.OK: - return - if status == Status.NOT_EXIST: - logger.info('模板仓库不存在, 正在尝试初始化...') - if status == Status.NOT_INITIALIZATION: - logger.warning('模板仓库状态异常, 尝试重新初始化') - rmtree(templates_dir) - if not await clone_repo( - repo_url='https://github.com/A-Minos/tetris-stats-templates', repo_path=templates_dir, branch='gh-pages' - ): - msg = '模板仓库初始化失败' - raise RuntimeError(msg) - logger.success('模板仓库初始化成功') - - -async def update_templates(repo_path: Path) -> bool: - logger.info('开始更新模板仓库...') - logger.info('拉取最新提交') - proc = await create_subprocess_exec('git', 'fetch', '--all', '--tags', stdout=PIPE, stderr=PIPE, cwd=repo_path) - await parse_log(proc) - if proc.returncode != 0: - logger.error('拉取最新提交失败') - return False - logger.success('拉取最新提交成功') +async def check_hash(hash_file_path: Path) -> bool: + logger.info('开始校验模板哈希值') + for i in hash_file_path.read_text().splitlines(): + file_sha256, file_relative_path = i.split(maxsplit=1) + file_path = hash_file_path.parent / file_relative_path + hasher = sha256() + if not file_path.is_file(): + logger.error(f'{file_path.name} 不存在或不是文件') + return False + async with open(file_path, 'rb') as file: + while True: + chunk = await file.read(65535) + if not chunk: + break + hasher.update(chunk) + if hasher.hexdigest() != file_sha256: + logger.error(f'{file_path.name} hash 不匹配') + return False + logger.debug(f'{file_path.name} hash 匹配成功') + logger.success('模板哈希值校验成功') return True -async def check_commit_hash(commit_hash: str, repo_path: Path, branch: str | None = None) -> bool: - output = await parse_log( - proc := await create_subprocess_exec( - 'git', 'branch', '--contains', commit_hash, stdout=PIPE, stderr=PIPE, cwd=repo_path - ) - ) - return ( - proc.returncode == 0 - and len(output.stdout) > 0 - and (branch is None or branch in output.stdout[0] or 'HEAD detached at' in output.stdout[0]) - ) +async def init_templates(tag: str) -> bool: + logger.info(f'开始初始化模板 {tag}') + temp_path = await unzip_templates(await download_templates(tag)) + if not await check_hash(temp_path / 'hash.sha256'): + rmtree(temp_path) + return False + if TEMPLATES_DIR.exists(): + logger.info('清除旧模板文件') + rmtree(TEMPLATES_DIR) + temp_path.rename(TEMPLATES_DIR) + logger.info('模板初始化完成') + return True -async def handle_tag(tag: str) -> str | None: - tags = ( - await parse_log(await create_subprocess_exec('git', 'tag', stdout=PIPE, stderr=PIPE, cwd=templates_dir)) - ).stdout - if tag not in tags: - logger.debug(f'{tag} 不为 tag') - return None - logger.info(f'{tag} 为 tag, 正在尝试 checkout 到 tag 对应的 gh-pages commit') - tag_commit_hash = ( - ( - await parse_log( - await create_subprocess_exec( - 'git', 'show-ref', '--tags', tag, stdout=PIPE, stderr=PIPE, cwd=templates_dir - ) - ) - ) - .stdout[0] - .split(maxsplit=1)[0] - ) - logger.success(f'tag 的 commit 为 {tag_commit_hash}') - commit_hash = ( - await parse_log( - await create_subprocess_exec( - 'git', - 'log', - 'gh-pages', - '--grep', - f'deploy: {tag_commit_hash}', - '--pretty=format:%H', - stdout=PIPE, - stderr=PIPE, - cwd=templates_dir, - ) - ) - ).stdout[0] - logger.info(f'找到疑似的 gh-pages commit {commit_hash}') - if await check_commit_hash(commit_hash, templates_dir, branch='gh-pages'): - logger.success('验证成功') - return commit_hash - logger.error('验证失败') - return None - - -@alc.handle() -async def _(revision: str): - if not await update_templates(templates_dir): - msg = '模板仓库更新失败' - logger.error(msg) - await UniMessage(msg).finish() - commit_hash = await handle_tag(revision) - if commit_hash is not None: - if await checkout(commit_hash, templates_dir): - msg = f'模板成功 checkout 到 {commit_hash}' - logger.success(msg) - await alc.finish(msg) - else: - logger.error('checkout 失败') - await alc.finish('checkout 失败') +async def check_tag(tag: str) -> bool: + async with AsyncClient() as client: + return ( + await client.get(f'https://github.com/A-Minos/tetris-stats-templates/releases/tag/{tag}') + ).status_code != HTTPStatus.NOT_FOUND @driver.on_startup async def _(): - await init_templates() + if (path := (TEMPLATES_DIR / 'hash.sha256')).is_file() and await check_hash(path): + logger.success('模板验证成功') + return + if not await init_templates('latest'): + msg = '模板初始化失败' + raise RuntimeError(msg) + + +@alc.handle() +async def _(revision: str | None = None): + if revision is not None and not await check_tag(revision): + await alc.finish(f'{revision} 不是模板仓库中的有效标签') + logger.info('开始更新模板') + if await init_templates(revision or 'latest'): + await alc.finish('更新模板成功') + await alc.finish('更新模板失败')