diff --git a/ccf_parser/base.py b/ccf_parser/base.py index bab828d..00c2b85 100644 --- a/ccf_parser/base.py +++ b/ccf_parser/base.py @@ -1,5 +1,5 @@ import pathlib -from typing import List, Literal +from typing import List, Literal, Optional from pydantic import BaseModel @@ -11,7 +11,6 @@ class CCFHeader(BaseModel): path: pathlib.Path description: str contest_type: Literal['OI', 'IOI'] - enable_oj: bool class Contest(BaseModel): diff --git a/example.env b/example.env new file mode 100644 index 0000000..7110fc5 --- /dev/null +++ b/example.env @@ -0,0 +1 @@ +SECRET="THISISASECRET" \ No newline at end of file diff --git a/main.py b/main.py index ddf396f..6df343a 100644 --- a/main.py +++ b/main.py @@ -1,8 +1,12 @@ +from manager import cli_app, start_server_background +from ccf_parser import CCF import json import pathlib -from ccf_parser import CCF -from manager import cli_app, start_server_background +import dotenv + +dotenv.load_dotenv() + if '__main__' == __name__: cli_app() diff --git a/manager/cli/base.py b/manager/cli/base.py index df6f5a9..3825d91 100644 --- a/manager/cli/base.py +++ b/manager/cli/base.py @@ -1,4 +1,5 @@ import shutil +import time import zipfile from pathlib import Path from typing import * @@ -13,6 +14,7 @@ from rich.progress import track from rich.text import Text +from online_judge import start_oj_background from utils import manager_logger from ..base import _start_server, start_server_background @@ -90,8 +92,16 @@ def intro(): @app.command(name='server') -def start_server_command(): # pragma: no cover - download_ited() +def start_server_command(manager: bool = True, oj: bool = True): # pragma: no cover + if manager: + download_ited() - manager_logger.info('访问 http://localhost:2568/editor 以访问ItsWA Manager。') - _start_server() + manager_logger.info( + '访问 http://localhost:2568/editor 以访问ItsWA Manager。') + start_server_background() + + if oj: + start_oj_background() + + while True: + time.sleep(10**9) diff --git a/online_judge/__init__.py b/online_judge/__init__.py new file mode 100644 index 0000000..84a4426 --- /dev/null +++ b/online_judge/__init__.py @@ -0,0 +1,2 @@ +from .base import app as oj_app +from .base import start_oj_background diff --git a/online_judge/auth.py b/online_judge/auth.py new file mode 100644 index 0000000..43bdcb3 --- /dev/null +++ b/online_judge/auth.py @@ -0,0 +1,107 @@ +import datetime +import os +from typing import * + +import fastapi +import jwt +import pydantic +from fastapi import APIRouter, Body, Depends, HTTPException +from fastapi.security import APIKeyCookie +from tinydb import Query + +from .oj_models.user import User +from .utils import usercol + +router = APIRouter(prefix='/auth', tags=['用户验证']) +apikey_schema = APIKeyCookie(name='itswa-oj-apikey') + + +def get_apikey_decoded(apikey: Optional[str] = Depends(apikey_schema)) -> Dict[Any, Any]: + if not apikey: + raise HTTPException( + status_code=401, detail="请提供API Key") + + try: + decoded = jwt.decode( + apikey, key=os.environ['SECRET'], algorithms=['HS256']) + except jwt.DecodeError: + raise HTTPException(status_code=401, detail="API Key无效") + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail="API Key已过期") + except Exception as e: + raise HTTPException(status_code=500, detail=f"未知错误: {e}") + + return decoded + + +def get_user(decoded: Dict[Any, Any] = Depends(get_apikey_decoded)) -> User: + try: + decoded: User = User(**decoded) + except pydantic.ValidationError: + raise HTTPException(status_code=401, detail="API Key无效") + + User_Query = Query() + result = usercol.search(User_Query.username == decoded.username)[0] + + return User(**result) + + +def get_token(user: User) -> str: + return jwt.encode( + { + **user.model_dump(mode='json'), + 'exp': datetime.datetime.now() + datetime.timedelta(days=7) + }, + os.environ['SECRET'] + ) + + +def require_role(roles: List[str] = ['default']) -> Callable[[User], User]: + def wrapper(user: User = Depends(get_user)) -> User: + if user.role not in roles: + raise HTTPException(status_code=403, detail="不符合权限要求") + + return user + + return wrapper + + +@router.post('/login', name='登录', responses={ + 200: { + "description": "登录成功", + "content": { + "application/json": { + "example": { + 'token': 'user_token' + } + } + } + } +}) +async def user_login(username: Annotated[str, Body()], password: Annotated[str, Body()]): + User_Query = Query() + results = usercol.search(User_Query.username == + username and User_Query.password == password) + + if results.__len__() >= 1: + return { + 'token': get_token(User.model_validate(results[0])) + } + else: + raise HTTPException(status_code=401, detail="用户名或密码错误") + + +@router.post('/register', name='注册', response_model=User) +async def user_register(username: Annotated[str, Body()], password: Annotated[str, Body()]): + User_Query = Query() + + if usercol.search(User_Query.username == username).__len__() >= 1: + raise HTTPException(status_code=409, detail="用户名已存在") + + usercol.insert(User( + username=username, + password=password, + role='default' + ).model_dump(mode='json')) + + return usercol.search(User_Query.username == username)[0] diff --git a/online_judge/base.py b/online_judge/base.py new file mode 100644 index 0000000..58f6197 --- /dev/null +++ b/online_judge/base.py @@ -0,0 +1,26 @@ +import multiprocessing + +import fastapi +import uvicorn + +from utils import online_judge_logger as logger + +from .auth import router as auth_router +from .contests import router as contests_router + +app = fastapi.FastAPI(title='ItsWA Online Judge API') +app.include_router(contests_router) +app.include_router(auth_router) + + +def _start_oj(): # proagma: no cover + logger.info('Online Judge API 启动, 地址 http://0.0.0.0:6572/') + uvicorn.run('online_judge:oj_app', host="0.0.0.0", port=6572, + workers=6, log_level='warning') + + +def start_oj_background(): # pragma: no cover + process = multiprocessing.Process(target=_start_oj) + process.start() + + return process diff --git a/online_judge/contests.py b/online_judge/contests.py new file mode 100644 index 0000000..c13dcab --- /dev/null +++ b/online_judge/contests.py @@ -0,0 +1,70 @@ +import json +import uuid +from datetime import datetime +from pathlib import Path +from typing import * + +import fastapi +from fastapi import APIRouter, Body, Depends, HTTPException +from tinydb import Query + +import ccf_parser + +from .auth import require_role +from .oj_models import OJContest +from .utils import contestscol +from .utils.dependencies import require_ccf_file + +router = APIRouter(prefix='/contests', tags=['比赛']) + + +@router.get('/', name='获取注册在 OJ 中的比赛', response_model=List[OJContest]) +async def get_contests(): + return contestscol.all() + + +@router.post('/', name='在 OJ 中注册一个已存在的比赛', response_model=OJContest, dependencies=[Depends(require_role('admin'))]) +async def register_contest_to_oj( + start_time: Annotated[datetime, Body()], + end_time: Annotated[datetime, Body()], + ccf_file: Path = Depends(require_ccf_file) +): + oj_contest = OJContest( + contest_id=uuid.uuid4(), + ccf_file=ccf_file, + start_time=start_time, + end_time=end_time + ) + + doc_id = contestscol.insert(oj_contest.model_dump(mode='json')) + + query = Query() + return contestscol.get(doc_id=doc_id) + + +@router.put('/{contest_id}', name='更新 OJ 中已注册的比赛', response_model=OJContest, dependencies=[Depends(require_role('admin'))]) +async def update_contest_in_oj(contest_id: str, contest: OJContest): + query = Query() + + doc_id = contestscol.upsert( + contest.model_dump(mode='json'), + query.contest_id == contest_id + )[0] + + return contestscol.get(doc_id=doc_id) + + +@router.delete('/{contest_id}', name='删除 OJ 中已注册的比赛', dependencies=[Depends(require_role('admin'))], responses={ + 200: { + 'description': '成功删除', + 'content': {'application/json': {'example': {'status': True}}} + } +}) +async def delete_contest_in_oj(contest_id: str): + query = Query() + + contestscol.remove(query.contest_id == contest_id) + + return { + 'status': True + } diff --git a/online_judge/oj_models/__init__.py b/online_judge/oj_models/__init__.py new file mode 100644 index 0000000..567d453 --- /dev/null +++ b/online_judge/oj_models/__init__.py @@ -0,0 +1,2 @@ +from .contest import OJContest +from .user import User diff --git a/online_judge/oj_models/contest.py b/online_judge/oj_models/contest.py new file mode 100644 index 0000000..0ca18f5 --- /dev/null +++ b/online_judge/oj_models/contest.py @@ -0,0 +1,21 @@ +from datetime import datetime +from pathlib import Path +from uuid import UUID + +from pydantic import BaseModel + +from ccf_parser import CCF + + +class OJContest(BaseModel): + """有别于 ccf_parser 中的 Contest,更加详细的比赛信息需要从 ccf_parser 中获取""" + contest_id: UUID + ccf_file: Path + + start_time: datetime + end_time: datetime + + @property + def read_ccf(self) -> CCF: + data = self.ccf_file.read_text('utf-8') + return CCF.model_validate_json(data) diff --git a/online_judge/oj_models/user.py b/online_judge/oj_models/user.py new file mode 100644 index 0000000..27c5771 --- /dev/null +++ b/online_judge/oj_models/user.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + + +class User(BaseModel): + username: str + password: str + role: str diff --git a/online_judge/utils/__init__.py b/online_judge/utils/__init__.py new file mode 100644 index 0000000..945948b --- /dev/null +++ b/online_judge/utils/__init__.py @@ -0,0 +1 @@ +from .database import contestscol, db, usercol diff --git a/online_judge/utils/database.py b/online_judge/utils/database.py new file mode 100644 index 0000000..1249329 --- /dev/null +++ b/online_judge/utils/database.py @@ -0,0 +1,7 @@ +import tinydb +from tinydb import TinyDB + +db = TinyDB('./assets/oj_db.json', indent=4, + ensure_ascii=False, sort_keys=True) +usercol = db.table('users') +contestscol = db.table('contests') diff --git a/online_judge/utils/dependencies/__init__.py b/online_judge/utils/dependencies/__init__.py new file mode 100644 index 0000000..279c143 --- /dev/null +++ b/online_judge/utils/dependencies/__init__.py @@ -0,0 +1 @@ +from .base import require_ccf_file diff --git a/online_judge/utils/dependencies/base.py b/online_judge/utils/dependencies/base.py new file mode 100644 index 0000000..1b1eea4 --- /dev/null +++ b/online_judge/utils/dependencies/base.py @@ -0,0 +1,25 @@ +from pathlib import Path + +import pydantic +from fastapi import HTTPException + +from ccf_parser import CCF + + +def require_ccf_file(ccf_file: Path) -> Path: + if ccf_file.name != "ccf.json": + raise HTTPException(status_code=400, detail="不是一个 CCF 文件") + + if not ccf_file.exists(): + raise HTTPException(status_code=404, detail="CCF 文件不存在") + + if not ccf_file.is_file(): + raise HTTPException(status_code=400, detail="不是一个有效的文件") + + try: + ccf = CCF.model_validate_json(ccf_file.read_text('utf-8')) + except pydantic.ValidationError as e: + raise HTTPException( + status_code=400, detail=f"CCF 文件格式错误: {e.errors()}") + + return ccf_file diff --git a/poetry.lock b/poetry.lock index 3e232f1..59a8a48 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1335,6 +1335,28 @@ type = "legacy" url = "https://pypi.tuna.tsinghua.edu.cn/simple" reference = "mirrors" +[[package]] +name = "pyjwt" +version = "2.8.0" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "PyJWT-2.8.0-py3-none-any.whl", hash = "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320"}, + {file = "PyJWT-2.8.0.tar.gz", hash = "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + +[package.source] +type = "legacy" +url = "https://pypi.tuna.tsinghua.edu.cn/simple" +reference = "mirrors" + [[package]] name = "pymdown-extensions" version = "10.7.1" @@ -1499,6 +1521,25 @@ type = "legacy" url = "https://pypi.tuna.tsinghua.edu.cn/simple" reference = "mirrors" +[[package]] +name = "python-dotenv" +version = "1.0.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.8" +files = [ + {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, + {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[package.source] +type = "legacy" +url = "https://pypi.tuna.tsinghua.edu.cn/simple" +reference = "mirrors" + [[package]] name = "pyyaml" version = "6.0.1" @@ -2070,4 +2111,4 @@ reference = "mirrors" [metadata] lock-version = "2.0" python-versions = ">=3.10,<4.0" -content-hash = "5f7250e8cac054f017d988fc870a6eb14576f554dc8255db9353526c06b66230" +content-hash = "f16126ccbbca1639e2117b92a9b33613105a0563df7fed581399840d8076ec80" diff --git a/pyproject.toml b/pyproject.toml index b017b21..8dc911a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,8 @@ requests = "^2.31.0" pytest-html = "^4.1.1" dominate = "^2.9.1" tinydb = "^4.8.0" +pyjwt = "^2.8.0" +python-dotenv = "^1.0.1" [[tool.poetry.source]]