├── app ├── plugins │ └── __init__.py ├── cli │ ├── __init__.py │ ├── base.py │ ├── cmd │ │ ├── get.py │ │ ├── stress.py │ │ ├── __init__.py │ │ └── load.py │ ├── models.py │ └── client.py ├── utils │ ├── __init__.py │ ├── tg.py │ ├── md.py │ ├── metrics.py │ └── log_helper.py ├── view │ ├── static │ │ ├── kksctf_logo_32.png │ │ ├── kksctf_logo_72.png │ │ ├── signin.css │ │ ├── admin.css │ │ ├── toast.css │ │ ├── id_resolver.js │ │ ├── style.css │ │ └── api_magic.js │ ├── templates │ │ ├── admin │ │ │ ├── index.jhtml │ │ │ ├── users_admin.jhtml │ │ │ ├── base.jhtml │ │ │ └── macro.jhtml │ │ ├── task.jhtml │ │ ├── scoreboard.jhtml │ │ ├── base.jhtml │ │ └── login.jhtml │ ├── admin │ │ └── __init__.py │ └── __init__.py ├── test │ ├── test_main.py │ ├── test_auth.py │ ├── __init__.py │ └── test_tasks_api.py ├── api │ ├── __init__.py │ ├── admin │ │ ├── __init__.py │ │ ├── admin_users.py │ │ └── admin_tasks.py │ ├── api_users.py │ ├── api_tasks.py │ └── api_auth.py ├── schema │ ├── __init__.py │ ├── auth │ │ ├── __init__.py │ │ ├── token_auth.py │ │ ├── auth_base.py │ │ ├── tg.py │ │ └── simple.py │ ├── flags.py │ ├── user.py │ ├── scoring.py │ ├── task.py │ └── ebasemodel.py ├── ws.py ├── __init__.py ├── main.py ├── db │ ├── __init__.py │ ├── db_users.py │ └── db_tasks.py ├── config.py └── auth.py ├── requirements-cli.txt ├── requirements-dev.txt ├── cli.py ├── docs ├── hacking │ └── index.md ├── index.md ├── release-notes.md └── config.md ├── nginx ├── Caddyfile └── yatb.conf ├── requirements-docs.txt ├── Dockerfile.test ├── yatb.example.env ├── Dockerfile.production ├── requirements.txt ├── main.py ├── .github └── workflows │ └── mkdocs.yml ├── flag_generator.py ├── .flake8 ├── README.md ├── mkdocs.yml ├── docker-compose.yml ├── .gitignore └── pyproject.toml /app/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | plugins = {} 2 | -------------------------------------------------------------------------------- /app/cli/__init__.py: -------------------------------------------------------------------------------- 1 | from .cmd import tapp 2 | -------------------------------------------------------------------------------- /app/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from . import log_helper, metrics 2 | 3 | # from . import tg 4 | -------------------------------------------------------------------------------- /requirements-cli.txt: -------------------------------------------------------------------------------- 1 | typer==0.9.0 2 | rich==13.5.2 3 | httpx==0.22.0 4 | pydantic-yaml==1.2.0 5 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pytest==7.1.2 2 | pytest-cov==2.10.1 3 | pytest-env==0.6.2 4 | httpx==0.22.0 5 | -------------------------------------------------------------------------------- /app/view/static/kksctf_logo_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kksctf/yatb/HEAD/app/view/static/kksctf_logo_32.png -------------------------------------------------------------------------------- /app/view/static/kksctf_logo_72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kksctf/yatb/HEAD/app/view/static/kksctf_logo_72.png -------------------------------------------------------------------------------- /cli.py: -------------------------------------------------------------------------------- 1 | from app.cli import tapp 2 | 3 | if __name__ == "__main__": 4 | tapp() 5 | # asyncio.run(amain()) 6 | -------------------------------------------------------------------------------- /docs/hacking/index.md: -------------------------------------------------------------------------------- 1 | # Hacking YATB 2 | 3 | One of the great things about the YATB is how easily you can fix and rework it for your needs 4 | -------------------------------------------------------------------------------- /nginx/Caddyfile: -------------------------------------------------------------------------------- 1 | { 2 | servers { 3 | trusted_proxies static private_ranges 4 | } 5 | } 6 | 7 | http:// { 8 | root /static/* /usr/caddy 9 | file_server /static/* 10 | 11 | reverse_proxy yatb:80 12 | } 13 | -------------------------------------------------------------------------------- /requirements-docs.txt: -------------------------------------------------------------------------------- 1 | mkdocs==1.4.2 2 | mkdocs-material==8.5.11 3 | mkdocs-material-extensions==1.1.1 4 | # For fixing nested list 5 | # Ref: https://github.com/mkdocs/mkdocs/issues/545#issuecomment-522196661 6 | mdx_truly_sane_lists==1.3 7 | -------------------------------------------------------------------------------- /Dockerfile.test: -------------------------------------------------------------------------------- 1 | FROM python:3.11 2 | 3 | COPY ./requirements.txt . 4 | COPY ./requirements-dev.txt . 5 | RUN pip install --no-cache-dir -r requirements.txt 6 | RUN pip install --no-cache-dir -r requirements-dev.txt 7 | 8 | CMD [ "python3" ] 9 | -------------------------------------------------------------------------------- /yatb.example.env: -------------------------------------------------------------------------------- 1 | DEBUG = False 2 | 3 | FLAG_BASE = ctf 4 | CTF_NAME = Example ctf 5 | 6 | JWT_SECRET_KEY = default_token_CHANGE_ME 7 | API_TOKEN = default_token_CHANGE_ME 8 | WS_API_TOKEN = default_token_CHANGE_ME 9 | FLAG_SIGN_KEY = default_token_CHANGE_ME 10 | 11 | -------------------------------------------------------------------------------- /app/test/test_main.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import pytest 4 | 5 | from .. import schema 6 | from . import TestClient, app 7 | from . import client as client_cl 8 | from . import test_auth 9 | 10 | client = client_cl 11 | 12 | 13 | def test_read_main(client: TestClient): 14 | resp = client.get("/") 15 | assert resp.status_code == 200 16 | -------------------------------------------------------------------------------- /app/view/static/signin.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100%; 3 | } 4 | 5 | .container-fluid { 6 | display: -ms-flexbox; 7 | display: flex; 8 | -ms-flex-align: center; 9 | align-items: center; 10 | padding-top: 40px; 11 | padding-bottom: 40px; 12 | background-color: #f5f5f5; 13 | } 14 | 15 | .form-signin { 16 | width: 100%; 17 | max-width: 330px; 18 | padding: 15px; 19 | margin: auto; 20 | } 21 | 22 | -------------------------------------------------------------------------------- /app/cli/base.py: -------------------------------------------------------------------------------- 1 | import typer 2 | from pydantic_settings import BaseSettings 3 | from rich.console import Console 4 | 5 | 6 | class Settings(BaseSettings): 7 | files_url: str = "http://127.0.0.1:9999" 8 | base_url: str = "http://127.0.0.1:8080" 9 | 10 | tasks_ip: str = "127.0.0.1" 11 | 12 | tasks_domain: str = "tasks.kksctf.ru" 13 | flag_base: str = "kks" 14 | 15 | 16 | settings = Settings() 17 | tapp = typer.Typer() 18 | c = Console() 19 | -------------------------------------------------------------------------------- /Dockerfile.production: -------------------------------------------------------------------------------- 1 | FROM python:3.11 2 | 3 | WORKDIR /usr/src 4 | 5 | COPY ./requirements.txt . 6 | RUN pip install --no-cache-dir -r requirements.txt 7 | 8 | RUN pip install "uvicorn[standard]" gunicorn 9 | 10 | COPY ./app ./app 11 | COPY ./main.py . 12 | 13 | # /usr/local/bin/uvicorn 14 | ENTRYPOINT [ "python3", "-m", "gunicorn", "main:app", "--worker-class=uvicorn.workers.UvicornWorker"] 15 | CMD ["--workers=1", "--log-level=warning", "--bind=0.0.0.0:80"] 16 | -------------------------------------------------------------------------------- /app/view/templates/admin/index.jhtml: -------------------------------------------------------------------------------- 1 | {% extends "admin/base.jhtml" %} 2 | 3 | {% block header %} 4 | {{ super() }} 5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 | {% for page in header_data.pages %} 10 | {{ page[0] }}
11 | {% endfor %} 12 |
13 | {% endblock %} 14 | 15 | {% block footer %} 16 | {{ super() }} 17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pydantic-settings==2.0.3 2 | pydantic==2.1.1 3 | fastapi==0.101.1 4 | 5 | aiofiles==0.8.0 6 | aiohttp==3.8.3 7 | python-jose[cryptography]==3.3.0 8 | Jinja2==3.1.2 9 | requests==2.28.1 10 | markdown==3.3.7 11 | uvicorn==0.17.6 12 | humanize==4.1.0 13 | prometheus-fastapi-instrumentator==5.6.0 14 | markupsafe==2.0.1 15 | websockets==10.4 16 | 17 | # beanie>=1.23.6,<2 18 | git+https://github.com/Rubikoid/beanie.git@encoder-fix 19 | 20 | git+https://github.com/kksctf/formgen.git@master 21 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from app import app 3 | 4 | if __name__ == "__main__": 5 | import uvicorn 6 | 7 | dev = 1 8 | if dev == 0: 9 | # use this one 10 | uvicorn.run(app, host="127.0.0.1", port=8080, log_level="info") 11 | if dev == 1: 12 | uvicorn.run("main:app", host="127.0.0.1", port=8080, log_level="debug", reload=False, debug=False) 13 | if dev == 2: 14 | uvicorn.run("main:app", host="127.0.0.1", port=8080, log_level="debug", workers=2) 15 | -------------------------------------------------------------------------------- /.github/workflows/mkdocs.yml: -------------------------------------------------------------------------------- 1 | name: Publish docs via GitHub Pages 2 | on: 3 | push: 4 | branches: 5 | - master 6 | 7 | jobs: 8 | build: 9 | name: Deploy docs 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout main 13 | uses: actions/checkout@v2 14 | 15 | - name: Deploy docs 16 | uses: mhausenblas/mkdocs-deploy-gh-pages@1.25 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | REQUIREMENTS: requirements-docs.txt 20 | -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from ..utils.log_helper import get_logger 4 | 5 | logger = get_logger("api") 6 | router = APIRouter( 7 | prefix="/api", 8 | tags=["api"], 9 | ) 10 | 11 | 12 | from . import admin # noqa 13 | from . import api_auth # noqa 14 | from . import api_tasks # noqa 15 | from . import api_users # noqa 16 | 17 | api_users.router.include_router(api_auth.router) 18 | router.include_router(api_users.router) 19 | router.include_router(api_tasks.router) 20 | router.include_router(admin.router) 21 | -------------------------------------------------------------------------------- /nginx/yatb.conf: -------------------------------------------------------------------------------- 1 | upstream production { 2 | server yatb:80; 3 | } 4 | 5 | server { 6 | listen 80; 7 | 8 | location / { 9 | client_max_body_size 50M; 10 | 11 | proxy_set_header Host $http_host; 12 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 13 | proxy_set_header X-Forwarded-Proto $scheme; 14 | 15 | proxy_redirect off; 16 | proxy_buffering off; 17 | 18 | proxy_pass http://production; 19 | } 20 | 21 | location /static/ { 22 | alias /usr/static/; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /flag_generator.py: -------------------------------------------------------------------------------- 1 | import hmac 2 | import binascii 3 | import uuid 4 | import sys 5 | 6 | 7 | def gen_flag(base: str, flag: str, key: str, user: uuid.UUID) -> str: 8 | flag_part = "{" + flag + "}" + f"{user}" 9 | hash = hmac.digest(key.encode(), flag_part.encode(), "sha256") 10 | return base + "{" + flag + "_" + binascii.hexlify(hash).decode()[0:14] + "}" 11 | 12 | 13 | if __name__ == "__main__": 14 | if len(sys.argv) != (1 + 4): 15 | print(f"Usage: {sys.argv[0]} ") 16 | 17 | print(gen_flag(*sys.argv[1:4], uuid.UUID(sys.argv[4]))) 18 | -------------------------------------------------------------------------------- /app/schema/__init__.py: -------------------------------------------------------------------------------- 1 | from ..utils.log_helper import get_logger 2 | from .auth import AuthBase, CTFTimeOAuth, OAuth, SimpleAuth, TelegramAuth 3 | from .ebasemodel import EBaseModel 4 | from .flags import DynamicKKSFlag, Flag, StaticFlag 5 | from .scoring import DynamicKKSScoring, Scoring, StaticScoring 6 | from .task import FlagUnion, ScoringUnion, Task, TaskForm 7 | from .user import User 8 | 9 | logger = get_logger("schema") 10 | 11 | 12 | class FlagForm(EBaseModel): 13 | flag: str 14 | 15 | 16 | # for i in [Task, User]: 17 | # logger.debug(f"Schema of {i} is {pprint.pformat(i.schema())}") 18 | -------------------------------------------------------------------------------- /app/view/templates/task.jhtml: -------------------------------------------------------------------------------- 1 | {% extends "base.jhtml" %} 2 | {% import "macro.jhtml" as macro with context %} 3 | 4 | {% block head %} 5 | {# {% set head_data.page_name = "All tasks" %} #} 6 | {{ super() }} 7 | {% endblock %} 8 | 9 | {% block header %} 10 | {% set header_data.yatb_logo_target = url_for('tasks_get_all') %} 11 | {{ super() }} 12 | {% endblock %} 13 | 14 | {% block content %} 15 |
16 | {{ macro.task_show(selected_task, curr_user, False, True, True, True) }} 17 |
18 | {% endblock %} 19 | 20 | {% block footer %} 21 | {{ super() }} 22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /app/view/static/admin.css: -------------------------------------------------------------------------------- 1 | html { 2 | position: relative; 3 | min-height: 100%; 4 | } 5 | 6 | body { 7 | margin-top: 48px; /* Margin top by header height */ 8 | margin-bottom: 60px; /* Margin bottom by footer height */ 9 | background-color: white; 10 | } 11 | 12 | body > nav { 13 | background-color: #404040; 14 | } 15 | 16 | .footer { 17 | position: absolute; 18 | bottom: 0; 19 | width: 100%; 20 | /* Set the fixed height of the footer here */ 21 | height: 60px; 22 | line-height: 60px; /* Vertically center the text there */ 23 | color: #909090; 24 | background-color: #404040; 25 | } 26 | 27 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 150 3 | ignore = 4 | F401 # module imported but unused 5 | # F403 # ‘from module import *’ used; unable to detect undefined names 6 | # F405 # name may be undefined, or defined from star imports: module 7 | # F841 # local variable name is assigned to but never used 8 | # E122 # continuation line missing indentation or outdented 9 | # E226 # missing whitespace around arithmetic operator 10 | # E265 # block comment should start with ‘# ‘ 11 | E402 # module level import not at top of file 12 | # E501 # line too long (82 > 79 characters) 13 | # E203 # errors with := operator 14 | # E231 # errors with := operator 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # YATB 2 | 3 | Documentation: 4 | 5 | ## Getting started 6 | 7 | ### dev 8 | 9 | `$ python3 -m pip install -r requirements-dev.txt` 10 | 11 | ### production 12 | 13 | `$ python3 -m pip install -r requirements.txt` 14 | 15 | ## Launch 16 | 17 | `$ python3 -m uvicorn main:app --host 0.0.0.0 --port 80` 18 | 19 | - `--log-level=debug`/`--log-level=info` - debug level. 20 | - `--reload`: if you want crazy autoreload, **can broke DB!** 21 | - `--proxy-headers`: if you want nginx 22 | - `--forwarded-allow-ips *`: if you want nginx 23 | 24 | ## Dev 25 | 26 | You can enable YATB_DEBUG env (`False` by default) and login through `/docs` or debug buttons on login page. 27 | 28 | First pair of buttons signing up and signing in as admin, second - as basic user 29 | 30 | #### made by `kks` 31 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: YATB 2 | site_description: YATB - yet another fast and furious jeopardy-CTF taskboard 3 | site_url: https://kksctf.github.io/yatb 4 | 5 | repo_name: kksctf/yatb 6 | repo_url: https://github.com/kksctf/yatb 7 | edit_uri: "" 8 | 9 | plugins: 10 | - search 11 | 12 | nav: 13 | - YATB: index.md 14 | - config.md 15 | - Hacking: 16 | - hacking/index.md 17 | - release-notes.md 18 | 19 | theme: 20 | name: material 21 | palette: 22 | # Palette toggle for dark mode 23 | - scheme: slate 24 | toggle: 25 | icon: material/brightness-4 26 | name: Switch to light mode 27 | 28 | # Palette toggle for light mode 29 | - scheme: default 30 | toggle: 31 | icon: material/brightness-7 32 | name: Switch to dark mode 33 | 34 | markdown_extensions: 35 | - mdx_truly_sane_lists 36 | -------------------------------------------------------------------------------- /app/ws.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from fastapi import WebSocket 4 | 5 | 6 | class ConnectionManager: 7 | def __init__(self) -> None: 8 | self.active_connections: list[WebSocket] = [] 9 | 10 | async def connect(self, websocket: WebSocket) -> None: 11 | await websocket.accept() 12 | self.active_connections.append(websocket) 13 | 14 | def disconnect(self, websocket: WebSocket) -> None: 15 | self.active_connections.remove(websocket) 16 | 17 | async def send_personal_message(self, message: str, websocket: WebSocket) -> None: 18 | await websocket.send_text(message) 19 | 20 | async def broadcast(self, message: str) -> None: 21 | for connection in self.active_connections: 22 | await connection.send_text(message) 23 | 24 | async def broadcast_json(self, data: Any, mode: str = "text") -> None: 25 | for connection in self.active_connections: 26 | await connection.send_json(data=data, mode=mode) 27 | 28 | 29 | ws_manager = ConnectionManager() 30 | -------------------------------------------------------------------------------- /app/view/static/toast.css: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Script47 (https://github.com/Script47/Toast) 3 | * @description Toast - A Bootstrap 4.2+ jQuery plugin for the toast component 4 | * @version 1.1.0 5 | **/ 6 | .toast-container { 7 | position: fixed; 8 | z-index: 1055; 9 | margin: 5px; 10 | } 11 | 12 | .top-right { 13 | top: 0; 14 | right: 0; 15 | } 16 | 17 | .top-left { 18 | top: 0; 19 | left: 0; 20 | } 21 | 22 | .top-center { 23 | transform: translateX(-50%); 24 | top: 0; 25 | left: 50%; 26 | } 27 | 28 | .bottom-right { 29 | right: 0; 30 | bottom: 0; 31 | } 32 | 33 | .bottom-left { 34 | left: 0; 35 | bottom: 0; 36 | } 37 | 38 | .bottom-center { 39 | transform: translateX(-50%); 40 | bottom: 0; 41 | left: 50%; 42 | } 43 | 44 | .toast-container > .toast { 45 | min-width: 150px; 46 | background: transparent; 47 | border: none; 48 | } 49 | 50 | .toast-container > .toast > .toast-header { 51 | border: none; 52 | } 53 | 54 | .toast-container > .toast > .toast-header strong { 55 | padding-right: 20px; 56 | } 57 | 58 | .toast-container > .toast > .toast-body { 59 | background: white; 60 | } 61 | -------------------------------------------------------------------------------- /app/schema/auth/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, TypeAlias, Union 2 | 3 | from pydantic import Field 4 | 5 | from ...config import settings 6 | from ...utils.log_helper import get_logger 7 | from .auth_base import AuthBase 8 | from .oauth import CTFTimeOAuth, DiscordOAuth, GithubOAuth, OAuth 9 | from .simple import SimpleAuth 10 | from .tg import TelegramAuth 11 | from .token_auth import TokenAuth 12 | 13 | logger = get_logger("schema.auth") 14 | 15 | ENABLED_AUTH_WAYS: list[type[AuthBase]] = [ 16 | TokenAuth, # type: ignore # types wtf 17 | ] 18 | 19 | for auth_way in settings.ENABLED_AUTH_WAYS: 20 | try: 21 | ENABLED_AUTH_WAYS.append(globals()[auth_way]) 22 | except KeyError as ex: 23 | logger.critical(f"{auth_way} not found") 24 | raise Exception("death") from ex # noqa: TRY002, EM101 25 | 26 | logger.info(f"Loaded next auth ways: {ENABLED_AUTH_WAYS}") 27 | 28 | RAW_AUTH_MODELS = tuple([x.AuthModel for x in ENABLED_AUTH_WAYS]) 29 | # TYPING_AUTH = ( 30 | # CTFTimeOAuth.AuthModel | SimpleAuth.AuthModel | TelegramAuth.AuthModel | GithubOAuth.AuthModel 31 | # ) 32 | TYPING_AUTH = Union[RAW_AUTH_MODELS] # type: ignore # noqa: UP007 33 | 34 | ANNOTATED_TYPING_AUTH = Annotated[ 35 | TYPING_AUTH, 36 | Field(discriminator="classtype"), 37 | ] 38 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | yatb: 5 | build: 6 | dockerfile: Dockerfile.production 7 | context: . 8 | command: "--workers=4 --bind=0.0.0.0:80 --forwarded-allow-ips='*'" 9 | # --log-level=warning --host 0.0.0.0 --port 80 --proxy-headers --forwarded-allow-ips *" 10 | # command: "--log-level=warning --host 0.0.0.0 --port 80" 11 | restart: always 12 | volumes: 13 | - ./yatb.env:/usr/src/yatb.env:ro 14 | - ./logs:/usr/src/logs 15 | environment: 16 | - "MONGO=mongodb://root:root@mongo:27017" 17 | depends_on: 18 | - mongo 19 | expose: 20 | - 80 21 | 22 | mongo: 23 | image: mongo:7.0 24 | environment: 25 | - "MONGO_INITDB_ROOT_USERNAME=root" 26 | - "MONGO_INITDB_ROOT_PASSWORD=root" 27 | volumes: 28 | - "yatb_data:/data/db" 29 | expose: 30 | - 27017 31 | 32 | caddy: 33 | image: caddy:2.7 34 | restart: always 35 | volumes: 36 | - "./app/view/static/:/usr/caddy/static/:ro" 37 | - "./nginx/Caddyfile:/etc/caddy/Caddyfile:ro" 38 | - "caddy_data:/data" 39 | ports: 40 | - "127.0.0.1:8080:80" 41 | 42 | # nginx: 43 | # image: nginx 44 | # restart: always 45 | # volumes: 46 | # - ./app/view/static/:/usr/static/ 47 | # - ./nginx/yatb.conf:/etc/nginx/conf.d/default.conf 48 | # ports: 49 | # - "127.0.0.1:8080:80" 50 | 51 | volumes: 52 | yatb_data: 53 | caddy_data: 54 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | 4 | from fastapi import FastAPI 5 | from fastapi.staticfiles import StaticFiles 6 | from fastapi.templating import Jinja2Templates 7 | 8 | from . import utils 9 | from .config import settings 10 | 11 | app = FastAPI( 12 | docs_url=settings.FASTAPI_DOCS_URL, 13 | redoc_url=settings.FASTAPI_REDOC_URL, 14 | openapi_url=settings.FASTAPI_OPENAPI_URL, 15 | ) 16 | 17 | _base_path = Path(__file__).resolve().parent 18 | app.mount("/static", StaticFiles(directory=_base_path / "view" / "static"), name="static") 19 | 20 | root_logger = utils.log_helper.root_logger 21 | 22 | loggers = [logging.getLogger()] # get the root logger 23 | loggers = loggers + [logging.getLogger(name) for name in logging.root.manager.loggerDict] 24 | 25 | # for i in loggers: 26 | # print(f"LOGGER: {i}") 27 | 28 | from . import api # noqa 29 | from . import main # noqa 30 | from . import view # noqa 31 | 32 | app.include_router(api.router) 33 | app.include_router(view.router) 34 | 35 | # prometheus 36 | from prometheus_fastapi_instrumentator import Instrumentator # noqa 37 | 38 | expose_url = settings.MONITORING_URL 39 | 40 | instrumentator = Instrumentator( 41 | excluded_handlers=[".*admin.*", expose_url], 42 | should_respect_env_var=True, 43 | env_var_name="ENABLE_METRICS", 44 | ) 45 | instrumentator.instrument(app).expose(app, endpoint=expose_url) 46 | # utils.metrics.bad_solves_per_user 47 | 48 | """ 49 | @app.on_event("startup") 50 | def startup_event(): 51 | metrics.load_all_metrics() 52 | 53 | 54 | @app.on_event("shutdown") 55 | def shutdown_event(): 56 | metrics.save_all_metrics() 57 | """ 58 | -------------------------------------------------------------------------------- /app/cli/cmd/get.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from ... import config 4 | from ..base import c, tapp 5 | from ..client import YATB 6 | 7 | 8 | @tapp.command() 9 | def get_all_tasks(): # noqa: ANN201 10 | async def _a(): 11 | async with YATB() as y: 12 | y.set_admin_token(config.settings.API_TOKEN) 13 | users = await y.get_all_users() 14 | tasks = await y.get_all_tasks() 15 | for task in tasks.values(): 16 | c.print(f"{task.task_id = }") 17 | c.print(f"{task.task_name = }") 18 | c.print(f"{task.category = }") 19 | c.print(f"{task.scoring = }") 20 | c.print(f"{task.flag = }") 21 | 22 | fancy_pwned_by = [f"'{users[user_id].username}'" for user_id in task.pwned_by] 23 | if fancy_pwned_by: 24 | c.print(f"pwned by: {', '.join(fancy_pwned_by)}") 25 | 26 | c.print() 27 | 28 | asyncio.run(_a()) 29 | 30 | 31 | @tapp.command() 32 | def get_all_users(): # noqa: ANN201 33 | async def _a(): 34 | async with YATB() as y: 35 | y.set_admin_token(config.settings.API_TOKEN) 36 | tasks = await y.get_all_tasks() 37 | users = await y.get_all_users() 38 | for user in users.values(): 39 | c.print(f"{user.user_id = }") 40 | c.print(f"{user.username = }") 41 | c.print(f"{user.is_admin = }") 42 | 43 | fancy_solved = [f"'{tasks[task_id].task_name}'" for task_id in user.solved_tasks] 44 | if fancy_solved: 45 | c.print(f"solved: {', '.join(fancy_solved)}") 46 | 47 | c.print() 48 | 49 | asyncio.run(_a()) 50 | -------------------------------------------------------------------------------- /app/schema/auth/token_auth.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | import os 4 | from collections.abc import Callable 5 | from typing import ClassVar, Hashable, Literal, Self 6 | 7 | from fastapi import HTTPException, Request, Response, status 8 | from pydantic_settings import SettingsConfigDict 9 | 10 | from ...config import settings 11 | from ...utils.log_helper import get_logger 12 | from ..ebasemodel import EBaseModel 13 | from .auth_base import AuthBase 14 | 15 | logger = get_logger("schema.auth") 16 | 17 | 18 | class TokenAuth: 19 | FAKE: bool = True 20 | 21 | class AuthModel(AuthBase.AuthModel): 22 | __public_fields__ = {"classtype"} 23 | __admin_only_fields__: ClassVar = { 24 | "username", 25 | } 26 | 27 | classtype: Literal["AuthBase"] = "AuthBase" 28 | 29 | username: str 30 | 31 | def is_admin(self) -> bool: 32 | return True 33 | 34 | def get_uniq_field(self) -> Hashable: 35 | return getattr(self, self.get_uniq_field_name()) 36 | 37 | @classmethod 38 | def get_uniq_field_name(cls: type[Self]) -> str: 39 | return "username" 40 | 41 | def generate_username(self) -> str: 42 | return self.username 43 | 44 | class Form(AuthBase.Form): 45 | async def populate(self, req: Request, resp: Response) -> "AuthBase.AuthModel": 46 | raise Exception("No.") 47 | 48 | class AuthSettings(AuthBase.AuthSettings): 49 | pass 50 | 51 | auth_settings: ClassVar[AuthSettings] = AuthSettings() 52 | 53 | router_params: ClassVar = {} 54 | 55 | @classmethod 56 | async def setup(cls: type[Self]) -> None: 57 | return None 58 | 59 | @classmethod 60 | def generate_html(cls: type[Self], url_for: Callable) -> str: 61 | return """""" 62 | 63 | @classmethod 64 | def generate_script(cls: type[Self], url_for: Callable) -> str: 65 | return """""" 66 | -------------------------------------------------------------------------------- /app/utils/tg.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | import requests 4 | 5 | from .. import schema 6 | from ..config import settings 7 | from ..utils.log_helper import get_logger 8 | 9 | logger = get_logger("api") 10 | 11 | 12 | def to_tg(data: dict, path: str) -> requests.Response: 13 | if not settings.BOT_TOKEN: 14 | return 15 | url = f"https://api.telegram.org/bot{settings.BOT_TOKEN}/{path}" 16 | ret = requests.post(url, data=data) 17 | logger.info(f"TG info={ret.text}") 18 | return ret 19 | 20 | 21 | def encoder(text: str) -> str: 22 | bad = ["_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!"] 23 | ret = text 24 | for b in bad: 25 | ret = ret.replace(b, f"\\{b}") 26 | return ret 27 | 28 | 29 | def send_message(text): 30 | return to_tg( 31 | data={ 32 | "chat_id": settings.CHAT_ID, 33 | "text": text, 34 | "parse_mode": "MarkdownV2", 35 | }, 36 | path="sendMessage", 37 | ) 38 | 39 | 40 | def display_fb_msg(what: schema.Task, by: schema.User): 41 | if by.auth_source.classtype == "CTFTimeOAuth": 42 | au = cast(schema.auth.CTFTimeOAuth.AuthModel, by.auth_source) 43 | message = ( 44 | f"First blood on {encoder(what.task_name)} by " 45 | f"[{encoder(by.username)}](https://ctftime.org/team/{au.team.id}), yay\\!" 46 | ) 47 | elif by.auth_source.classtype == "TelegramAuth": 48 | au = cast(schema.auth.TelegramAuth.AuthModel, by.auth_source) 49 | if au.tg_username: 50 | message = f"First blood on {encoder(what.task_name)} by @{encoder(au.tg_username)}, yay\\!" 51 | else: 52 | message = f"First blood on {encoder(what.task_name)} by {encoder(by.username)}, yay\\!" 53 | 54 | else: 55 | message = f"First blood on {encoder(what.task_name)} by {encoder(by.username)}, yay\\!" 56 | 57 | return send_message(message) 58 | -------------------------------------------------------------------------------- /app/schema/flags.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | import hmac 3 | from typing import ClassVar, Literal 4 | 5 | from ..config import settings 6 | from .ebasemodel import EBaseModel 7 | from .user import User 8 | 9 | 10 | class Flag(EBaseModel): 11 | __public_fields__: ClassVar = {"classtype"} 12 | __admin_only_fields__: ClassVar = {"flag_base"} 13 | 14 | classtype: Literal["Flag"] = "Flag" 15 | flag_base: str = settings.FLAG_BASE 16 | 17 | def sanitization(self, user_flag: str) -> str: 18 | if self.flag_base + "{" in user_flag: 19 | user_flag = user_flag.replace(self.flag_base + "{", "", 1) 20 | if user_flag[-1] == "}": 21 | user_flag = user_flag[:-1] 22 | user_flag = self.flag_base + "{" + user_flag + "}" 23 | 24 | return user_flag 25 | 26 | def flag_value(self, user: User) -> str: 27 | return self.flag_base + "{test_flag}" 28 | 29 | def flag_checker(self, user_flag: str, user: User) -> bool: 30 | if self.flag_value(user) == self.sanitization(user_flag): 31 | return True 32 | else: 33 | return False 34 | 35 | 36 | class StaticFlag(Flag): 37 | __admin_only_fields__: ClassVar = {"flag_base", "flag"} 38 | 39 | classtype: Literal["StaticFlag"] = "StaticFlag" 40 | flag: str 41 | 42 | def flag_value(self, user: User) -> str: 43 | return self.flag_base + "{" + self.flag + "}" 44 | 45 | 46 | class DynamicKKSFlag(Flag): 47 | __admin_only_fields__: ClassVar = {"flag_base", "dynamic_flag_base"} 48 | 49 | classtype: Literal["DynamicKKSFlag"] = "DynamicKKSFlag" 50 | dynamic_flag_base: str 51 | 52 | def flag_value(self, user: User) -> str: 53 | flag_part = "{" + self.dynamic_flag_base + "}" + f"{user.user_id}" 54 | hash = hmac.digest(settings.FLAG_SIGN_KEY.encode(), flag_part.encode(), "sha256") 55 | return self.flag_base + "{" + self.dynamic_flag_base + "_" + binascii.hexlify(hash).decode()[0:14] + "}" 56 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Welcome to YATB Docs 2 | 3 | YATB - yet another fast and furious jeopardy-CTF taskboard 4 | 5 | ## Features out-of-the-box 6 | 7 | - Flexible auth system with OAuth2 support 8 | - Flexible flags and scoring system 9 | - OpenAPI schema and swagger UI - easy to integrate API 10 | - Extremly simple: you can modificate in any imagniable way 11 | - Telegram notifications for first blood 12 | - Blazing :) fast! 13 | 14 | ## How to start 15 | 16 | 1. Clone repo 17 | 2. Fix event datetime: 18 | 1. Open [app/config.py](https://github.com/kksctf/yatb/blob/master/app/config.py), setup `EVENT_START_TIME` and `EVENT_END_TIME`. Theese dates **MUST** be in UTC. 19 | 2. or in `yatb.env`. 20 | 3. Copy `yatb.example.env` to `yatb.env`, change next values: 21 | 1. tokens: 22 | 1. `JWT_SECRET_KEY` - secret key for JWT cookie sign 23 | 2. `API_TOKEN` - token for automated usage of admin API 24 | 3. `WS_API_TOKEN` - token for admin WS 25 | 4. `FLAG_SIGN_KEY` - flag sign key 26 | 2. `FLAG_BASE` - flag base (part before brackets), i.e. for flag `kks{example_flag}` flag base is `kks`. 27 | 3. `CTF_NAME` - CTF name for frontend 28 | 4. Setup auth ways: 29 | 1. Fill `ENABLED_AUTH_WAYS` list with enabled auth ways, for example, `ENABLED_AUTH_WAYS='["TelegramAuth", "SimpleAuth"]'` 30 | 2. Fill select auth way settings. For reference, see [more about auth ways configs](config.md#Auth%20ways) 31 | 5. [More about config](config.md) 32 | 4. Change logos in [app/view/static](https://github.com/kksctf/yatb/tree/master/app/view/static) 33 | 5. `docker-compose up -d --build` 34 | 6. If you setup any reverse proxy before YATB nginx, you should change `proxy_set_header X-Forwarded-Proto $scheme;` line in [nginx/yatb.conf](https://github.com/kksctf/yatb/blob/master/nginx/yatb.conf#L9): comment entire line or replace `$scheme;` with `https`. 35 | 36 | ## Stack 37 | 38 | - [FastAPI](https://github.com/tiangolo/fastapi) 39 | - [Pydantic](https://github.com/pydantic/pydantic) 40 | - [Jinja](https://github.com/pallets/jinja) 41 | - Bootstrap 42 | -------------------------------------------------------------------------------- /app/cli/cmd/stress.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import httpx 4 | 5 | from ..base import c, tapp 6 | from ..client import YATB 7 | from ..models import RawUser 8 | 9 | 10 | @tapp.command() 11 | def setup_test_env(*, heavy: bool = False): # noqa: ANN201 12 | total = 500 if heavy else 6 13 | lim1 = 200 if heavy else 6 14 | lim2 = 300 if heavy else 6 15 | lim3 = 350 if heavy else 6 16 | 17 | users_to_create = [ 18 | RawUser( 19 | username=f"test_user_{i}", 20 | password="1", # noqa: S106 21 | ) 22 | for i in range(total) 23 | ] 24 | 25 | async def _a(): 26 | async with YATB() as y: 27 | y.set_admin_token() 28 | await y.detele_everything() 29 | 30 | users = {} 31 | 32 | for task in tasks_to_create: 33 | new_task = await y.create_task(task) 34 | new_task.hidden = False 35 | await y.update_task(new_task) 36 | c.log(f"Task created: {new_task = }") 37 | 38 | for i, user in enumerate(users_to_create): 39 | users[i] = await y.register_user(user) 40 | 41 | for i in range(0, lim1, 2): 42 | await y.solve_as_user(users[i], "A") 43 | await y.solve_as_user(users[i], "B") 44 | 45 | for i in range(0, lim2, 3): 46 | await y.solve_as_user(users[i], "B") 47 | await y.solve_as_user(users[i], "C") 48 | 49 | for i in range(0, lim3, 5): 50 | await y.solve_as_user(users[i], "As") 51 | 52 | asyncio.run(_a()) 53 | 54 | 55 | @tapp.command() 56 | def make_load(url: str): 57 | async def _a(): 58 | async with httpx.AsyncClient() as c: 59 | 60 | async def task(): 61 | print("Start") 62 | for _ in range(0, 1000): 63 | r = await c.get(url) 64 | _ = r.text 65 | print("End") 66 | 67 | async with asyncio.TaskGroup() as tg: 68 | for _ in range(0, 10): 69 | tg.create_task(task()) 70 | 71 | asyncio.run(_a()) 72 | -------------------------------------------------------------------------------- /app/schema/user.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import uuid 3 | from typing import ClassVar, Literal, Self 4 | 5 | from pydantic import Field, model_validator 6 | 7 | from ..utils.log_helper import get_logger 8 | from .auth import ANNOTATED_TYPING_AUTH 9 | from .auth.auth_base import AuthBase 10 | from .ebasemodel import EBaseModel 11 | 12 | logger = get_logger("schema.user") 13 | 14 | 15 | class User(EBaseModel): 16 | __public_fields__: ClassVar = { 17 | "user_id", 18 | "username", 19 | "score", 20 | "solved_tasks", 21 | "affilation", 22 | "country", 23 | "profile_pic", 24 | } 25 | __admin_only_fields__: ClassVar = { 26 | "is_admin", 27 | "auth_source", 28 | } 29 | __private_fields__: ClassVar = set() 30 | 31 | user_id: uuid.UUID = Field(default_factory=uuid.uuid4) 32 | 33 | username: str = "unknown" 34 | 35 | score: int = 0 36 | 37 | solved_tasks: dict[uuid.UUID, datetime.datetime] = {} # uuid or task :hm 38 | is_admin: bool = False 39 | 40 | affilation: str = "" 41 | country: str = "" 42 | 43 | profile_pic: str | None = None 44 | 45 | auth_source: ANNOTATED_TYPING_AUTH # type: ignore 46 | 47 | @property 48 | def au_s(self) -> AuthBase.AuthModel: # WTF: dirty hack... ;( 49 | return self.auth_source 50 | 51 | @model_validator(mode="after") 52 | def setup_fields(self) -> Self: 53 | self.username = self.au_s.generate_username() 54 | if self.admin_checker() and not self.is_admin: 55 | logger.warning(f"Promoting {self} to admin") 56 | self.is_admin = True 57 | 58 | return self 59 | 60 | def admin_checker(self) -> bool: 61 | return self.au_s.is_admin() 62 | 63 | def get_last_solve_time(self) -> tuple[uuid.UUID, datetime.datetime] | tuple[Literal[""], datetime.datetime]: 64 | if len(self.solved_tasks) > 0: 65 | return max(self.solved_tasks.items(), key=lambda x: x[1]) 66 | 67 | return ("", datetime.datetime.fromtimestamp(0, tz=datetime.UTC)) 68 | 69 | def short_desc(self) -> str: 70 | return f"user_id={self.user_id} username={self.username} authsrc={self.au_s.classtype}" 71 | -------------------------------------------------------------------------------- /app/schema/scoring.py: -------------------------------------------------------------------------------- 1 | import math 2 | from typing import ClassVar, Literal 3 | 4 | from pydantic import computed_field 5 | 6 | from ..utils.log_helper import get_logger 7 | from .ebasemodel import EBaseModel 8 | 9 | logger = get_logger("schema.scoring") 10 | 11 | 12 | class Scoring(EBaseModel): 13 | __public_fields__: ClassVar = { 14 | "classtype", 15 | "points", 16 | } 17 | classtype: Literal["Scoring"] = "Scoring" 18 | 19 | @computed_field 20 | @property 21 | def points(self) -> int: 22 | return -1337 23 | 24 | def solve_task(self) -> bool: 25 | return False 26 | 27 | def set_solves(self, count: int) -> None: 28 | pass 29 | 30 | def reset(self) -> None: 31 | pass 32 | 33 | # class Config: 34 | # extra = Extra.allow 35 | 36 | 37 | class StaticScoring(Scoring): 38 | __admin_only_fields__: ClassVar = {"static_points"} 39 | classtype: Literal["StaticScoring"] = "StaticScoring" 40 | 41 | static_points: int 42 | 43 | @computed_field 44 | @property 45 | def points(self) -> int: 46 | return self.static_points 47 | 48 | def solve_task(self) -> bool: 49 | return False 50 | 51 | 52 | class DynamicKKSScoring(Scoring): 53 | __admin_only_fields__: ClassVar = {"solves", "decay", "minimum", "maximum"} 54 | classtype: Literal["DynamicKKSScoring"] = "DynamicKKSScoring" 55 | 56 | solves: int = 0 57 | decay: int = 50 58 | minimum: int = 100 59 | maximum: int = 1000 60 | 61 | @computed_field 62 | @property 63 | def points(self) -> int: 64 | if self.solves == 0: 65 | return self.maximum 66 | if self.solves >= self.decay: 67 | return self.minimum 68 | 69 | coeff = 495 - (1 - math.pow(self.decay / (10**6), 0.25)) * 65.91 * math.log(self.decay) 70 | out = self.maximum - coeff * math.log(self.solves) 71 | if out > self.maximum: 72 | logger.warning(f"Wtf why more than maximum at {self}") 73 | return min(max(self.minimum, math.ceil(out)), self.maximum) 74 | 75 | def solve_task(self) -> bool: 76 | self.solves += 1 77 | return True 78 | 79 | def set_solves(self, count: int) -> None: 80 | self.solves = count 81 | 82 | def reset(self) -> None: 83 | self.set_solves(0) 84 | -------------------------------------------------------------------------------- /app/schema/auth/auth_base.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable, Hashable 2 | from typing import ClassVar, Literal, Self, TypeAlias 3 | 4 | from fastapi import Request, Response 5 | from pydantic_settings import BaseSettings, SettingsConfigDict 6 | 7 | from ..ebasemodel import EBaseModel 8 | 9 | RouterParams: TypeAlias = dict[str, str | object] 10 | 11 | 12 | class AuthBase: 13 | FAKE: bool = False 14 | 15 | class AuthModel(EBaseModel): 16 | __public_fields__ = {"classtype"} 17 | 18 | classtype: Literal["AuthBase"] = "AuthBase" 19 | 20 | def is_admin(self) -> bool: 21 | # raise NotImplementedError("AuthBase.is_admin not implemented") 22 | return False 23 | 24 | def get_uniq_field(self) -> Hashable: 25 | return getattr(self, self.get_uniq_field_name()) 26 | 27 | @classmethod 28 | def get_uniq_field_name(cls: type[Self]) -> str: 29 | return "unknown" 30 | 31 | @classmethod 32 | def get_classtype(cls: type[Self]) -> str: 33 | # intended solution https://github.com/pydantic/pydantic/issues/7179 34 | return cls.model_fields["classtype"].default 35 | 36 | def generate_username(self) -> str: 37 | # raise NotImplementedError("AuthBase.generate_username not implemented") 38 | return "undefined" 39 | 40 | class Form(EBaseModel): 41 | async def populate(self, req: Request, resp: Response) -> "AuthBase.AuthModel": 42 | # raise NotImplementedError("AuthBase.Form.populate not implemented") 43 | return AuthBase.AuthModel() 44 | 45 | class AuthSettings(BaseSettings): 46 | model_config = SettingsConfigDict( 47 | env_file="yatb.env", 48 | env_file_encoding="utf-8", 49 | extra="allow", 50 | ) 51 | 52 | auth_settings: ClassVar[AuthSettings] = AuthSettings() 53 | router_params: ClassVar[RouterParams] = { 54 | "path": "/base_handler", 55 | "name": "api_auth_base_handler", 56 | "methods": ["GET"], 57 | } 58 | 59 | @classmethod 60 | async def setup(cls: type[Self]) -> None: 61 | # router.add_api_route( 62 | # f"{cls.handler_name}", 63 | # ) 64 | return None 65 | 66 | @classmethod 67 | def generate_html(cls: type[Self], url_for: Callable) -> str: 68 | return """""" 69 | 70 | @classmethod 71 | def generate_script(cls: type[Self], url_for: Callable) -> str: 72 | return """""" 73 | -------------------------------------------------------------------------------- /app/view/static/id_resolver.js: -------------------------------------------------------------------------------- 1 | const resolver_settings = [ 2 | { 3 | class: "user_id_resolve", 4 | api_url: "api_users_get_username", 5 | path_key: "user_id", 6 | result_key: "", 7 | cache: {}, 8 | cache_key: "user_id_cache" 9 | }, 10 | { 11 | class: "task_id_resolve", 12 | api_url: "api_task_get", 13 | path_key: "task_id", 14 | result_key: "task_name", 15 | cache: {}, 16 | cache_key: "task_id_cache" 17 | }, 18 | ] 19 | 20 | $(".flag_submit_form").submit(function (event) { 21 | event.preventDefault(); 22 | 23 | req(api_list["api_task_submit_flag"], { data: getFormData(this), }) 24 | .then(get_json) 25 | .then(ok_toast_generator("Solve flag"), nok_toast_generator("Solve flag")) 26 | .then((resp) => { 27 | console.log(resp); 28 | if (typeof (resp.json) == "string" && resp.json.includes('-')) { 29 | var task = $("div[data-id=" + resp.json + "]"); 30 | task.addClass("solved"); 31 | console.log("found task", task); 32 | } 33 | }) 34 | }); 35 | 36 | $(function () { 37 | resolver_settings.forEach(async function (item) { 38 | item.cache = JSON.parse(localStorage.getItem(item.cache_key)) || item.cache; 39 | 40 | let item_list = []; 41 | $("." + item.class).each(function (index) { item_list.push(this); }); 42 | for (item_index in item_list) { 43 | othis = item_list[item_index] 44 | let jthis = $(othis); 45 | let id = jthis.text().trim(); 46 | if (item.cache.hasOwnProperty(id)) { 47 | jthis.text(item.cache[id]); 48 | jthis.removeClass(item.class); 49 | othis.style = ""; 50 | } 51 | else { 52 | path_params = {}; 53 | path_params[item.path_key] = id; 54 | try { 55 | result = await preq(api_list[item.api_url], path_params, { method: "GET", }).then(get_json); 56 | let result_data = (item.result_key != "" ? result.json[item.result_key] : result.json); 57 | item.cache[id] = result_data; 58 | jthis.text(result_data); 59 | jthis.removeClass(item.class); 60 | othis.style = ""; 61 | } catch (err) { 62 | console.error(item, path_params, err); 63 | } 64 | } 65 | } 66 | localStorage.setItem(item.cache_key, JSON.stringify(item.cache)); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | from . import app, root_logger 2 | from .api import api_tasks 3 | from .config import settings 4 | 5 | """ 6 | @app.middleware("http") 7 | async def session_middleware(request: Request, call_next): 8 | # start_time = time.time() 9 | response = await call_next(request) 10 | # process_time = time.time() - start_time 11 | # response.headers["X-Process-Time"] = str(process_time) 12 | return response 13 | """ 14 | 15 | if settings.DEBUG: 16 | try: 17 | import fastapi # noqa 18 | import pydantic # noqa 19 | from asgi_server_timing import ServerTimingMiddleware # noqa # type: ignore 20 | 21 | root_logger.warning("Timing debug loading") 22 | 23 | app.add_middleware( 24 | ServerTimingMiddleware, 25 | calls_to_track={ 26 | "1deps": (fastapi.routing.solve_dependencies,), # type: ignore 27 | "2main": (fastapi.routing.run_endpoint_function,), 28 | # "3valid": (pydantic.fields.ModelField.validate,), 29 | "4encode": (fastapi.encoders.jsonable_encoder,), # type: ignore 30 | "5render": ( 31 | fastapi.responses.JSONResponse.render, 32 | fastapi.responses.ORJSONResponse.render, 33 | fastapi.responses.HTMLResponse.render, 34 | fastapi.responses.PlainTextResponse.render, 35 | ), 36 | # "6tasks": (api_tasks.api_tasks_get,), 37 | # "6task": (api_tasks.api_task_get,), 38 | }, 39 | ) 40 | root_logger.warning("Timing debug loaded") 41 | 42 | except ModuleNotFoundError: 43 | root_logger.warning("No timing extensions found") 44 | 45 | if settings.PROFILING: 46 | try: 47 | from fastapi import Request 48 | from fastapi.responses import HTMLResponse 49 | from pyinstrument import Profiler 50 | 51 | root_logger.warning("pyinstrument loading") 52 | 53 | @app.middleware("http") 54 | async def profile_request(request: Request, call_next): 55 | profiling = request.query_params.get("profile", False) 56 | if profiling: 57 | profiler = Profiler(async_mode="enabled") # interval=settings.profiling_interval 58 | profiler.start() 59 | await call_next(request) 60 | profiler.stop() 61 | return HTMLResponse(profiler.output_html()) 62 | else: 63 | return await call_next(request) 64 | 65 | root_logger.warning("pyinstrument loaded") 66 | 67 | except ModuleNotFoundError: 68 | root_logger.warning("No pyinstrument found") 69 | -------------------------------------------------------------------------------- /app/view/static/style.css: -------------------------------------------------------------------------------- 1 | @media (min-width: 34em) { 2 | .card-columns { 3 | -webkit-column-count: 2; 4 | -moz-column-count: 2; 5 | column-count: 2; 6 | } 7 | } 8 | 9 | @media (min-width: 48em) { 10 | .card-columns { 11 | -webkit-column-count: 2; 12 | -moz-column-count: 2; 13 | column-count: 2; 14 | } 15 | } 16 | 17 | @media (min-width: 62em) { 18 | .card-columns { 19 | -webkit-column-count: 3; 20 | -moz-column-count: 3; 21 | column-count: 3; 22 | } 23 | } 24 | 25 | @media (min-width: 75em) { 26 | .card-columns { 27 | -webkit-column-count: 4; 28 | -moz-column-count: 4; 29 | column-count: 4; 30 | } 31 | } 32 | 33 | @media (min-width: 100em) { 34 | .card-columns { 35 | -webkit-column-count: 5; 36 | -moz-column-count: 5; 37 | column-count: 5; 38 | } 39 | } 40 | 41 | html { 42 | position: relative; 43 | min-height: 100%; 44 | } 45 | 46 | body { 47 | margin-top: 48px; 48 | /* Margin top by header height */ 49 | margin-bottom: 60px; 50 | /* Margin bottom by footer height */ 51 | background-color: #585858; 52 | } 53 | 54 | body>nav { 55 | background-color: #404040; 56 | } 57 | 58 | .footer { 59 | position: absolute; 60 | bottom: 0; 61 | width: 100%; 62 | /* Set the fixed height of the footer here */ 63 | height: 60px; 64 | line-height: 60px; 65 | /* Vertically center the text there */ 66 | color: #909090; 67 | background-color: #404040; 68 | } 69 | 70 | div.card>* a { 71 | text-decoration: none; 72 | } 73 | 74 | div.card.solved:not(.secondary) { 75 | background: repeating-linear-gradient(135deg, 76 | #404040, 77 | #404040 20px, 78 | #585858 20px, 79 | #585858 40px); 80 | color: white; 81 | } 82 | 83 | div.card.solved.secondary { 84 | background: #404040; 85 | color: white; 86 | } 87 | 88 | div.card.solved>div.card-header>*>* { 89 | color: greenyellow; 90 | } 91 | 92 | div.card.solved>.card-body>.card-text>a { 93 | color: #a2d5f2; 94 | } 95 | 96 | div.card.crypto:not(.solved)>* .task-category, 97 | .crypto-color { 98 | color: #3c6e71; 99 | } 100 | 101 | div.card.binary:not(.solved)>* .task-category, 102 | .binary-color { 103 | color: #D11149; 104 | } 105 | 106 | div.card.web:not(.solved)>* .task-category, 107 | .web-color { 108 | color: #6610F2; 109 | } 110 | 111 | div.card.forensic:not(.solved)>* .task-category, 112 | .forensic-color { 113 | color: #E6C229; 114 | } 115 | 116 | div.card.other:not(.solved)>* .task-category, 117 | .other-color { 118 | color: #f17105; 119 | } 120 | 121 | .scoreboard-indicator-fontsize { 122 | font-size: min(3vw, 1.7em); 123 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | # vscode 141 | .vscode/ 142 | 143 | # db 144 | *.db 145 | *.sqlite 146 | *.db.local 147 | 148 | logs/ 149 | 150 | # env 151 | yatb.env 152 | -------------------------------------------------------------------------------- /app/test/test_auth.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: S101, S106, ANN201 # this is a __test file__ 2 | 3 | from fastapi import status 4 | 5 | from .. import config, schema 6 | from . import ClientEx, app 7 | from . import client as client_cl 8 | 9 | client = client_cl 10 | 11 | LoginForm = schema.SimpleAuth.Form._Internal # noqa: SLF001 12 | 13 | 14 | def test_register(client: ClientEx): 15 | resp = client.simple_register_raw(username="Rubikoid", password="123") 16 | 17 | assert resp.status_code == status.HTTP_200_OK, resp.text 18 | assert resp.text == '"ok"', resp.text 19 | 20 | 21 | def test_login(client: ClientEx): 22 | test_register(client) 23 | resp = client.simple_login_raw(username="Rubikoid", password="123") 24 | 25 | assert resp.status_code == status.HTTP_200_OK, resp.text 26 | assert resp.text == '"ok"', resp.text 27 | 28 | 29 | def test_admin(client: ClientEx): 30 | # config.settings.DEBUG = True 31 | test_login(client) 32 | # config.settings.DEBUG = False 33 | resp = client.get(app.url_path_for("api_admin_users_me")) 34 | # print(resp.json()) 35 | assert resp.status_code == status.HTTP_200_OK, resp.text 36 | assert resp.json()["is_admin"] is True, resp.json() 37 | assert resp.json()["username"] == "Rubikoid", resp.json() 38 | 39 | 40 | def test_admin_fail(client: ClientEx): 41 | resp1 = client.simple_register_raw(username="Not_Rubikoid", password="123") 42 | assert resp1.status_code == status.HTTP_200_OK, resp1.text 43 | assert resp1.text == '"ok"', resp1.text 44 | 45 | resp2 = client.simple_login_raw(username="Not_Rubikoid", password="123") 46 | assert resp2.status_code == status.HTTP_200_OK, resp2.text 47 | assert resp2.text == '"ok"', resp2.text 48 | 49 | resp3 = client.get(app.url_path_for("api_admin_users_me")) 50 | assert resp3.status_code == status.HTTP_403_FORBIDDEN, resp3.text 51 | 52 | 53 | def test_not_existing_user(client: ClientEx): 54 | resp1 = client.post( 55 | app.url_path_for("api_auth_simple_login"), 56 | json=LoginForm(username="Not_Existing_Account", password="123").model_dump(mode="json"), 57 | ) 58 | assert resp1.status_code == status.HTTP_401_UNAUTHORIZED, resp1.text 59 | 60 | 61 | def test_invalid_password(client: ClientEx): 62 | resp1 = client.simple_register_raw(username="Not_Rubikoid", password="123") 63 | assert resp1.status_code == status.HTTP_200_OK, resp1.text 64 | assert resp1.text == '"ok"', resp1.text 65 | 66 | resp2 = client.simple_login_raw(username="Not_Rubikoid", password="1234") 67 | assert resp2.status_code == status.HTTP_401_UNAUTHORIZED, resp2.text 68 | 69 | 70 | def test_register_existing_user(client: ClientEx): 71 | resp1 = client.simple_register_raw(username="Not_Rubikoid", password="123") 72 | assert resp1.status_code == status.HTTP_200_OK, resp1.text 73 | assert resp1.text == '"ok"', resp1.text 74 | 75 | resp2 = client.simple_register_raw(username="Not_Rubikoid", password="1234") 76 | assert resp2.status_code == status.HTTP_403_FORBIDDEN, resp2.text 77 | -------------------------------------------------------------------------------- /app/api/admin/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from fastapi import APIRouter, Depends, Header, HTTPException, Query, status 4 | 5 | from ... import auth, schema 6 | from ...config import settings 7 | from ...db.beanie import TaskDB, UserDB 8 | from ...utils.log_helper import get_logger 9 | 10 | _fake_admin_user = schema.User( 11 | username="token_bot", 12 | is_admin=True, 13 | auth_source=schema.auth.TokenAuth.AuthModel(username="hardcoded_token"), 14 | ) 15 | 16 | 17 | async def admin_checker( 18 | user: auth.CURR_USER_SAFE, 19 | token_header: str | None = Header(None, alias="X-Token"), 20 | token_query: str | None = Query(None, alias="token"), 21 | ) -> schema.User: 22 | if user and user.is_admin: 23 | return user 24 | 25 | if token_header and token_header == settings.API_TOKEN: 26 | return _fake_admin_user 27 | if token_query and token_query == settings.API_TOKEN: 28 | return _fake_admin_user 29 | 30 | raise HTTPException( 31 | status_code=status.HTTP_403_FORBIDDEN, 32 | detail="No.", 33 | ) 34 | 35 | 36 | CURR_ADMIN = Annotated[schema.User, Depends(admin_checker)] 37 | 38 | logger = get_logger("api.admin") 39 | router = APIRouter( 40 | prefix="/admin", 41 | tags=["admin"], 42 | ) 43 | 44 | 45 | # @router.get("/save_db") 46 | # async def save_db(user: CURR_ADMIN): 47 | # await db.shutdown_event() 48 | # logger.warning(f"DB saved by {user.short_desc()}") 49 | 50 | 51 | @router.delete("/db_users") 52 | async def api_detele_everything_but_tasks(admin: CURR_ADMIN) -> None: 53 | if not settings.DEBUG: 54 | logger.error(f"{admin} чистить юзеров на проде") 55 | raise HTTPException( 56 | status_code=status.HTTP_403_FORBIDDEN, 57 | detail="unacceptable", 58 | ) 59 | 60 | for user in (await UserDB.get_all()).values(): 61 | if not user.is_admin: 62 | await user.delete() # type: ignore # WTF: great library 63 | 64 | for task in (await TaskDB.get_all()).values(): 65 | task.pwned_by.clear() 66 | await task.save() # type: ignore # WTF: great library 67 | 68 | 69 | @router.delete("/db") 70 | async def api_detele_everything(admin: CURR_ADMIN, *, force: bool = False) -> None: 71 | if not settings.DEBUG and admin.username != "hardcoded_token": 72 | logger.error(f"{admin} чистит бд!") 73 | raise HTTPException( 74 | status_code=status.HTTP_403_FORBIDDEN, 75 | detail="unacceptable", 76 | ) 77 | 78 | for user in (await UserDB.get_all()).values(): 79 | if user.is_admin: 80 | continue 81 | if len(user.solved_tasks) and not force: 82 | continue 83 | 84 | await user.delete() # type: ignore # WTF: great library 85 | 86 | for task in (await TaskDB.get_all()).values(): 87 | if len(task.pwned_by) and not force: 88 | continue 89 | 90 | await task.delete() # type: ignore # WTF: great library 91 | 92 | 93 | from . import admin_tasks # noqa 94 | from . import admin_users # noqa 95 | -------------------------------------------------------------------------------- /app/utils/md.py: -------------------------------------------------------------------------------- 1 | import markdown 2 | from typing import Dict, Optional 3 | 4 | 5 | class ClassAdderTreeprocessor(markdown.treeprocessors.Treeprocessor): 6 | def run(self, root): 7 | self.set_css_class(root) 8 | return root 9 | 10 | def set_config(self, ext): 11 | self.ext = ext 12 | 13 | def set_css_class(self, element): 14 | for child in element: 15 | # if child.tag == "p": 16 | # child.set("class", self.ext.getConfig("css_class")) # set the class attribute 17 | cl = self.ext.get_class_for_tag(child.tag) 18 | if cl: 19 | child.set("class", cl) 20 | self.set_css_class(child) # run recursively on children 21 | 22 | 23 | class ClassAdderExtension(markdown.Extension): 24 | def __init__(self, *args, **kwargs): 25 | self.config = { 26 | "replace": [ 27 | {}, 28 | "replace - Default: {}", 29 | ], 30 | } 31 | # Override defaults with user settings 32 | for key, value in kwargs.items(): 33 | self.setConfig(key, value) 34 | 35 | def get_class_for_tag(self, tag) -> Optional[str]: 36 | if tag in self.getConfig("replace"): 37 | return self.getConfig("replace")[tag] 38 | return None 39 | 40 | def extendMarkdown(self, md, md_globals): 41 | treeprocessor = ClassAdderTreeprocessor(md) 42 | treeprocessor.set_config(self) 43 | md.treeprocessors.register(treeprocessor, "class-ext", 0) 44 | 45 | 46 | class AttributeTreeprocessor(markdown.treeprocessors.Treeprocessor): 47 | def run(self, root): 48 | self.set_attrs(root) 49 | return root 50 | 51 | def set_config(self, ext): 52 | self.ext = ext 53 | 54 | def set_attrs(self, element): 55 | for child in element: 56 | attrs = self.ext.get_attrs_for_tag(child.tag) 57 | if attrs: 58 | for attr_name in attrs: 59 | child.set(attr_name, attrs[attr_name]) 60 | self.set_attrs(child) # run recursively on children 61 | 62 | 63 | class AttributeExtension(markdown.Extension): 64 | def __init__(self, *args, **kwargs): 65 | self.config = {"attrs": [{}, "attrs - Default: {}"]} 66 | # Override defaults with user settings 67 | for key, value in kwargs.items(): 68 | self.setConfig(key, value) 69 | 70 | def get_attrs_for_tag(self, tag) -> Optional[str]: 71 | if tag in self.getConfig("attrs"): 72 | return self.getConfig("attrs")[tag] 73 | return None 74 | 75 | def extendMarkdown(self, md, md_globals): 76 | treeprocessor = AttributeTreeprocessor(md) 77 | treeprocessor.set_config(self) 78 | md.treeprocessors.register(treeprocessor, "attrib-ext", 0) 79 | # ['css'] = 80 | 81 | 82 | def markdownCSS(txt, config, attrs_config={}): 83 | ext = ClassAdderExtension(replace=config) 84 | ext_attrs = AttributeExtension(attrs=attrs_config) 85 | md = markdown.Markdown(extensions=[ext, ext_attrs], safe_mode="escape") 86 | html = md.convert(txt) 87 | return html 88 | -------------------------------------------------------------------------------- /app/cli/models.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | import uuid 4 | from dataclasses import dataclass 5 | from datetime import datetime 6 | 7 | from pydantic import BaseModel, RootModel 8 | 9 | from .. import schema 10 | from .base import settings 11 | 12 | 13 | class UserPublic(schema.EBaseModel): 14 | user_id: uuid.UUID 15 | username: str 16 | 17 | score: int 18 | solved_tasks: dict[uuid.UUID, datetime] 19 | 20 | 21 | class UserPrivate(UserPublic): 22 | is_admin: bool 23 | 24 | 25 | @dataclass 26 | class RawUser: 27 | username: str 28 | password: str = "0" 29 | 30 | def generate_password(self) -> None: 31 | self.password = "".join(random.choices(string.ascii_letters, k=16)) # noqa: S311 # i. knew. 32 | 33 | 34 | @dataclass 35 | class RawTask: 36 | task_name: str 37 | category: str 38 | description: str 39 | 40 | flag: str 41 | 42 | author: str = "" 43 | 44 | 45 | class FileTask(BaseModel): 46 | name: str 47 | description: str 48 | 49 | author: str 50 | category: str 51 | 52 | flag: str 53 | is_gulag: bool = False 54 | 55 | warmup: bool = False 56 | server_port: int | None = None 57 | 58 | is_http: bool = True 59 | domain_prefix: str | None = None 60 | 61 | @property 62 | def full_name(self) -> str: 63 | return self.name 64 | 65 | def get_raw(self) -> RawTask: 66 | name = self.full_name 67 | 68 | description = self.description.strip().strip('"').strip("'") 69 | 70 | if self.server_port: 71 | if self.is_http: 72 | if self.domain_prefix: 73 | server_addr = f"https://{self.domain_prefix}.{settings.tasks_domain}/" 74 | else: 75 | server_addr = f"http://{settings.tasks_ip}:{self.server_port}" 76 | 77 | description += "\n\n---\n\n" 78 | description += '
' 79 | description += ( 80 | f"{server_addr}\n" 83 | ) 84 | description += "
" 85 | else: 86 | description += ( 87 | "\n\n---\n\n" 88 | f"`nc {settings.tasks_ip} {self.server_port}`" 89 | "\n" # 90 | ) 91 | 92 | flag = self.flag 93 | if flag.startswith(settings.flag_base + "{") and flag.endswith("}"): 94 | flag = flag.removeprefix(settings.flag_base + "{") 95 | flag = flag.removesuffix("}") 96 | 97 | return RawTask( 98 | task_name=name, 99 | category=self.category, 100 | description=description, 101 | flag=flag, 102 | author=self.author, 103 | ) 104 | 105 | 106 | AllUsers = RootModel[dict[uuid.UUID, UserPrivate]] 107 | AllTasks = RootModel[dict[uuid.UUID, schema.Task]] 108 | -------------------------------------------------------------------------------- /app/utils/metrics.py: -------------------------------------------------------------------------------- 1 | from threading import Lock 2 | import pickle 3 | from typing import Callable 4 | from prometheus_client.metrics import MetricWrapperBase 5 | from prometheus_fastapi_instrumentator.metrics import Info 6 | from prometheus_client import Counter, Gauge 7 | 8 | # some flag statistic 9 | solves_per_user = Counter( 10 | "solves_per_user", 11 | "Number of solves, per users", 12 | labelnames=( 13 | "user_id", 14 | "username", 15 | ), 16 | ) 17 | 18 | bad_solves_per_user = Counter( 19 | "bad_solves_per_user", 20 | "Number of solves, per users", 21 | labelnames=( 22 | "user_id", 23 | "username", 24 | ), 25 | ) 26 | 27 | solves_per_task = Counter( 28 | "solves_per_task", 29 | "Number of solves, per tasks", 30 | labelnames=( 31 | "task_id", 32 | "task_name", 33 | ), 34 | ) 35 | 36 | score_per_user = Gauge( 37 | "score_per_user", 38 | "Score per user", 39 | labelnames=( 40 | "user_id", 41 | "username", 42 | ), 43 | ) 44 | 45 | # some users statistic 46 | 47 | users = Counter("users", "Number of registred users") 48 | 49 | logons_per_user = Counter( 50 | "logons_per_user", 51 | "Number of logons, per users", 52 | labelnames=( 53 | "user_id", 54 | "username", 55 | ), 56 | ) 57 | 58 | 59 | def save_all_metrics(): 60 | data = {} 61 | try: 62 | for metric in [ 63 | "solves_per_user", 64 | "bad_solves_per_user", 65 | "solves_per_task", 66 | "score_per_user", 67 | "users", 68 | "logons_per_user", 69 | ]: 70 | m: MetricWrapperBase = globals()[metric] 71 | k = data[metric] = {} 72 | 73 | if hasattr(m, "_value"): 74 | k["_val"] = m._value 75 | k["_val"]._lock = None 76 | 77 | for _k, _v in m._metrics.items(): 78 | if hasattr(_v, "_value"): 79 | k[_k] = _v._value 80 | k[_k]._lock = None 81 | 82 | with open("metrics_dump.pickle", "wb") as f: 83 | pickle.dump(data, f) 84 | except Exception as ex: 85 | print(ex) 86 | 87 | 88 | def load_all_metrics(): 89 | data = {} 90 | try: 91 | with open("metrics_dump.pickle", "rb") as f: 92 | data = pickle.load(f) 93 | 94 | for metric in [ 95 | "solves_per_user", 96 | "bad_solves_per_user", 97 | "solves_per_task", 98 | "score_per_user", 99 | "users", 100 | "logons_per_user", 101 | ]: 102 | m: MetricWrapperBase = globals()[metric] 103 | k: dict = data[metric] 104 | 105 | if "_val" in k: 106 | m._value = k["_val"] 107 | m._value._lock = Lock() 108 | 109 | for _k, _v in k.items(): 110 | if _k == "_val": 111 | continue 112 | m._metrics[_k] = _v 113 | _v._lock = Lock() 114 | 115 | except Exception as ex: 116 | print(ex) 117 | -------------------------------------------------------------------------------- /app/view/templates/scoreboard.jhtml: -------------------------------------------------------------------------------- 1 | {% extends "base.jhtml" %} 2 | {% import "macro.jhtml" as macro with context %} 3 | 4 | {% block head %} 5 | {% set head_data.page_name = "Scoreboard" %} 6 | {{ super() }} 7 | {% endblock %} 8 | 9 | {% block content %} 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {% for i, user in enumerate(scoreboard) %} 22 | {% if len(user.solved_tasks) > 0 %} 23 | {% set last_solve_info = user.get_last_solve_time() %} 24 | {% endif %} 25 | 26 | 27 | 30 | 31 | 54 | 55 | {% endfor %} 56 | 57 |
#usernamescore
{{ i + 1 }} 28 |
{{ user.username }}
29 |
{{ user.score }} 32 |
33 | {% for task in all_tasks|sort(attribute='scoring.points')|sort(attribute='category') %} 34 | {% set tooltip = str(task.scoring.points) + " | " + task.task_name + " | " + task.category %} 35 | {% if task.task_id in user.solved_tasks %} 36 | {% set tooltip = tooltip + " | " + template_format_time(user.solved_tasks[task.task_id]) %} 37 | {% endif %} 38 | 39 |
40 | {% if task.task_id in user.solved_tasks %} 41 | {% if task.task_id == last_solve_info[0] %} 42 | 43 |
44 | {% else %} 45 |
46 | {% endif %} 47 | {% else %} 48 |
49 | {% endif %} 50 |
51 | {% endfor %} 52 |
53 |
58 |
59 | {% endblock %} 60 | 61 | {% block footer %} 62 | {{ super() }} 63 | {% endblock %} 64 | -------------------------------------------------------------------------------- /app/api/admin/admin_users.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import Annotated, Mapping 3 | 4 | from fastapi import APIRouter, Cookie, Depends, FastAPI, Header, HTTPException, Query, Request, Response, status 5 | from pydantic import BaseModel 6 | 7 | from ... import auth, schema 8 | from ...config import settings 9 | from ...db.beanie import TaskDB, UserDB 10 | from ...utils.log_helper import get_logger 11 | from . import CURR_ADMIN, logger, router 12 | 13 | 14 | async def api_admin_users_internal() -> Mapping[uuid.UUID, schema.User]: 15 | all_users = await UserDB.get_all() 16 | return all_users 17 | 18 | 19 | async def api_admin_user_get_internal(user_id: uuid.UUID) -> UserDB: 20 | user = await UserDB.find_by_user_uuid(user_id) 21 | if not user: 22 | raise HTTPException( 23 | status_code=status.HTTP_404_NOT_FOUND, 24 | detail="User not found", 25 | ) 26 | 27 | return user 28 | 29 | 30 | class PasswordChangeForm(BaseModel): 31 | new_password: str 32 | 33 | 34 | @router.get("/user/{user_id}") 35 | async def api_admin_user(user_id: uuid.UUID, user: CURR_ADMIN) -> schema.User.admin_model: 36 | ret_user = await api_admin_user_get_internal(user_id) 37 | return ret_user 38 | 39 | 40 | @router.post("/user/{user_id}") 41 | async def api_admin_user_edit(new_user: schema.User, user_id: uuid.UUID, user: CURR_ADMIN) -> schema.User.admin_model: 42 | new_user = await db.update_user_admin(user_id, new_user) 43 | return new_user 44 | 45 | 46 | @router.get("/users/me") 47 | async def api_admin_users_me(user: CURR_ADMIN) -> schema.User.admin_model: 48 | return user 49 | 50 | 51 | @router.get("/users") 52 | async def api_admin_users(user: CURR_ADMIN) -> Mapping[uuid.UUID, schema.User.admin_model]: 53 | all_users = await api_admin_users_internal() 54 | return all_users 55 | 56 | 57 | @router.post("/user/{user_id}/password") 58 | async def api_admin_user_edit_password( 59 | new_password: PasswordChangeForm, 60 | admin: CURR_ADMIN, 61 | user: schema.User = Depends(api_admin_user_get_internal), 62 | ) -> schema.User.admin_model: 63 | au = user.auth_source 64 | if not isinstance(au, schema.auth.SimpleAuth.AuthModel): 65 | raise HTTPException( 66 | status_code=status.HTTP_400_BAD_REQUEST, 67 | detail="User is not login-passw sourced", 68 | ) 69 | au.password_hash = schema.auth.simple.hash_password(new_password.new_password) 70 | return user 71 | 72 | 73 | @router.get("/user/{user_id}/score") 74 | async def api_admin_user_recalc_score( 75 | admin: CURR_ADMIN, 76 | user: UserDB = Depends(api_admin_user_get_internal), 77 | ) -> schema.User.admin_model: 78 | await user.recalc_score_one() 79 | return user 80 | 81 | 82 | @router.delete("/user/{user_id}") 83 | async def api_admin_user_delete( 84 | admin: CURR_ADMIN, 85 | user: schema.User = Depends(api_admin_user_get_internal), 86 | ) -> str: 87 | if not user: 88 | raise HTTPException( 89 | status_code=status.HTTP_404_NOT_FOUND, 90 | detail="user not exist", 91 | ) 92 | 93 | if len(user.solved_tasks) > 0: 94 | raise HTTPException( 95 | status_code=status.HTTP_403_FORBIDDEN, 96 | detail="user have solved tasks", 97 | ) 98 | await db.delete_user(user) 99 | return "deleted" 100 | -------------------------------------------------------------------------------- /app/view/static/api_magic.js: -------------------------------------------------------------------------------- 1 | function value_handler(ret, name, value, list = false) { 2 | var parts = name.split("."); 3 | fdict = ret; 4 | parts.slice(0, -1).forEach((val) => { 5 | if (!fdict.hasOwnProperty(val)) 6 | fdict[val] = {}; 7 | fdict = fdict[val]; 8 | }); 9 | if (!list) 10 | fdict[parts[parts.length - 1]] = value; 11 | else { 12 | if (fdict[parts[parts.length - 1]] == undefined) { 13 | fdict[parts[parts.length - 1]] = [] 14 | } 15 | fdict[parts[parts.length - 1]].push(value); 16 | } 17 | } 18 | 19 | function getFormData(f) { 20 | var inputs = $(f).find(":input"); 21 | var ret = {}; 22 | $(inputs).each(function (index, obj) { 23 | obj_j = $(obj); 24 | obj_name = obj.name; 25 | obj_type = obj.type; 26 | if (obj_name !== undefined && obj_name != "" && !obj_j.hasClass("form_class_disabled") && !obj_j.hasClass("form_class_selector")) { 27 | if (obj_type !== undefined && obj_name != "") { 28 | if (obj_type == "checkbox") { 29 | if (obj_j.val() != "on") { 30 | if (obj.checked) 31 | value_handler(ret, obj_name, obj_j.val(), list = true); 32 | } 33 | else 34 | value_handler(ret, obj_name, obj.checked); 35 | } 36 | else 37 | value_handler(ret, obj_name, obj_j.val()); 38 | } 39 | else 40 | value_handler(ret, obj_name, obj_j.val()); 41 | } 42 | }); 43 | console.log("NEW", ret); 44 | return ret; 45 | } 46 | 47 | function init_form_class() { 48 | $("select.form_class_selector").change(function () { 49 | var selected = this.value; 50 | var propname = this.dataset["propname"]; 51 | var div = $(".form_class_selector_list > .form_class_selector_class[data-ref=" + selected + "][data-propname=" + propname + "]"); 52 | var other_div = $(".form_class_selector_list > .form_class_selector_class[data-ref!=" + selected + "][data-propname=" + propname + "]"); 53 | div.find(":input").removeClass("form_class_disabled").prop("disabled", false); 54 | other_div.find(":input").addClass("form_class_disabled").prop("disabled", true); 55 | div.show(); 56 | other_div.hide(); 57 | }); 58 | $("select.form_class_selector").change(); 59 | 60 | $('.form-select').select2({ 61 | theme: "bootstrap-5", 62 | closeOnSelect: true, 63 | }); 64 | $('.form-select-multiple').select2({ 65 | theme: "bootstrap-5", 66 | closeOnSelect: false, 67 | }); 68 | } 69 | 70 | function ok_toast_generator(toast_name) { 71 | return (data) => { 72 | $.toast({ 73 | type: 'success', 74 | title: toast_name, 75 | subtitle: 'now', 76 | content: '
' + JSON.stringify(data.json) + '
', 77 | delay: 5000, 78 | }); 79 | return data; 80 | }; 81 | } 82 | 83 | function nok_toast_generator(toast_name, pass = false) { 84 | return (data) => { 85 | $.toast({ 86 | type: 'error', 87 | title: toast_name, 88 | subtitle: 'now', 89 | content: data, 90 | delay: 5000, 91 | }); 92 | if (pass) 93 | throw data; 94 | }; 95 | } 96 | 97 | redirect = (ret) => { location.pathname = "/"; } 98 | -------------------------------------------------------------------------------- /app/cli/cmd/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import uuid 3 | 4 | import typer 5 | 6 | from ... import config 7 | from ...schema.task import Task 8 | 9 | # 10 | from ..base import c, tapp 11 | from ..client import YATB 12 | from ..models import RawTask 13 | 14 | # 15 | from . import get as get_cmds 16 | from . import load as load_cmds 17 | from . import stress as stress_cmds 18 | 19 | get_cmds = get_cmds 20 | stress_cmds = stress_cmds 21 | load_cmds = load_cmds 22 | 23 | tasks_to_create: list[RawTask] = [ 24 | RawTask( 25 | task_name="test_task_1", 26 | category="web", 27 | description="flag - A\n", 28 | flag="A", 29 | ), 30 | RawTask( 31 | task_name="test_task_2", 32 | category="web", 33 | description="flag - B\n", 34 | flag="B", 35 | ), 36 | RawTask( 37 | task_name="test_task_3", 38 | category="web", 39 | description="flag - C\n", 40 | flag="C", 41 | ), 42 | RawTask( 43 | task_name="test_task_1_separate", 44 | category="web", 45 | description="flag - As\n", 46 | flag="As", 47 | ), 48 | RawTask( 49 | task_name="test_task_2_separate", 50 | category="web", 51 | description="flag - Bs\n", 52 | flag="Bs", 53 | ), 54 | RawTask( 55 | task_name="test_task_3_separate", 56 | category="web", 57 | description="flag - Cs\n", 58 | flag="Cs", 59 | ), 60 | ] 61 | 62 | 63 | @tapp.command() 64 | def drop_users(): # noqa: ANN201 65 | shure = typer.prompt("Are you shure? [y/N]", default="N") 66 | if shure not in ["y", "yes"]: 67 | return 68 | 69 | async def _a(): 70 | async with YATB() as y: 71 | y.set_admin_token(config.settings.API_TOKEN) 72 | await y.detele_everything_but_tasks() 73 | 74 | asyncio.run(_a()) 75 | 76 | 77 | @tapp.command() 78 | def init_tasks(): # noqa: ANN201 79 | async def _a(): 80 | async with YATB() as y: 81 | y.set_admin_token(config.settings.API_TOKEN) 82 | 83 | for task in tasks_to_create: 84 | new_task = await y.create_task(task) 85 | c.log(f"Task created: {new_task = }") 86 | 87 | asyncio.run(_a()) 88 | 89 | 90 | @tapp.command() 91 | def cleanup(): # noqa: ANN201 92 | async def _a(): 93 | async with YATB() as y: 94 | y.set_admin_token() 95 | await y.detele_everything() 96 | 97 | asyncio.run(_a()) 98 | 99 | 100 | @tapp.command() 101 | def recalc(): 102 | async def _a(): 103 | async with YATB() as y: 104 | y.set_admin_token() 105 | 106 | await y.admin_recalc_tasks() 107 | await y.admin_recalc_scoreboard() 108 | 109 | asyncio.run(_a()) 110 | 111 | 112 | @tapp.command() 113 | def unhide(): 114 | async def _a(): 115 | async with YATB() as y: 116 | y.set_admin_token() 117 | 118 | async with asyncio.TaskGroup() as tg: 119 | for task in (await y.get_all_tasks()).values(): 120 | task.hidden = False 121 | tg.create_task(y.update_task(task)) 122 | 123 | asyncio.run(_a()) 124 | 125 | 126 | # @tapp.command() 127 | # def cmd(): # noqa: CCR001,ANN201 128 | # async def _a(): 129 | # async with YATB() as y: 130 | # y.set_admin_token(config.settings.API_TOKEN) 131 | 132 | # asyncio.run(_a()) 133 | -------------------------------------------------------------------------------- /app/db/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pickle 3 | from datetime import datetime 4 | 5 | from pydantic import BaseModel 6 | 7 | from .. import app, db, schema 8 | from ..config import settings 9 | from ..utils.log_helper import get_logger 10 | from .beanie import TaskDB, UserDB 11 | 12 | logger = get_logger("db") 13 | 14 | 15 | class FileDB: 16 | _db = None 17 | _index = None 18 | 19 | def __init__(self): 20 | self.reset_db() 21 | 22 | def reset_db(self): 23 | self._index = { 24 | "tasks": {}, 25 | "users": {}, 26 | "short_urls": {}, 27 | } 28 | self._db = { 29 | "tasks": {}, 30 | "users": {}, 31 | } 32 | 33 | def generate_index(self): 34 | for i, v in self._db["tasks"].items(): 35 | self._index["tasks"][v.task_id] = self.update_task(v) 36 | 37 | for i, v in self._db["users"].items(): 38 | self._index["users"][v.user_id] = self.update_user(v) 39 | 40 | def update_task(self, task: schema.Task): 41 | # regenerate markdown 42 | task.description_html = schema.Task.regenerate_md(task.description) 43 | 44 | return task 45 | 46 | def update_user(self, user: schema.User): 47 | # FIXME: говнокод & быстрофикс. 48 | if isinstance(user.auth_source, dict): 49 | original_au = user.auth_source 50 | cls: schema.auth.AuthBase.AuthModel = getattr(schema.auth, user.auth_source["classtype"]).AuthModel 51 | user.auth_source = cls.model_validate(user.auth_source) 52 | logger.warning(f"Found & fixed broken auth source: {original_au} -> {user.auth_source}") 53 | 54 | # admin promote 55 | if user.admin_checker() and not user.is_admin: 56 | logger.warning(f"INIT: Promoting {user} to admin") 57 | user.is_admin = True 58 | 59 | return user 60 | 61 | 62 | _db = FileDB() 63 | 64 | 65 | @app.on_event("startup") 66 | async def startup_event(): 67 | return 68 | global _db 69 | if settings.DB_NAME is None: 70 | _db.reset_db() 71 | logger.warning("TESTING_FileDB loaded") 72 | return 73 | 74 | if not os.path.exists(settings.DB_NAME): 75 | _db._db = { 76 | "tasks": {}, 77 | "users": {}, 78 | } 79 | else: 80 | try: 81 | with open(settings.DB_NAME, "rb") as f: 82 | _db._db = pickle.load(f) 83 | except Exception as ex: 84 | _db._db = { 85 | "tasks": {}, 86 | "users": {}, 87 | } 88 | logger.error(f"Loading db exception, fallback to empty, {ex}") 89 | 90 | _db.generate_index() 91 | logger.warning("FileDB loaded") 92 | # logger.debug(f"FileDB: {_db._db}") 93 | # logger.debug(f"FileDBIndex: {_db._index}") 94 | 95 | 96 | @app.on_event("shutdown") 97 | async def shutdown_event(): 98 | return 99 | global _db 100 | if settings.DB_NAME is None: 101 | return 102 | save_path = settings.DB_NAME / "ressurect_db.db" if settings.DB_NAME.is_dir() else settings.DB_NAME 103 | with open(settings.DB_NAME, "wb") as f: 104 | pickle.dump(_db._db, f) 105 | logger.warning("FileDB saved") 106 | 107 | 108 | def update_entry(obj: BaseModel, data: dict): 109 | for i in data: 110 | if i in obj.__fields__: 111 | setattr(obj, i, data[i]) 112 | 113 | 114 | # from .db_tasks import * # noqa 115 | # from .db_users import * # noqa 116 | -------------------------------------------------------------------------------- /app/db/db_users.py: -------------------------------------------------------------------------------- 1 | from app.db import update_entry 2 | import uuid 3 | import logging 4 | from typing import Hashable, List, Dict, Optional, Type 5 | 6 | from .. import schema 7 | from ..utils.log_helper import get_logger 8 | 9 | logger = get_logger("db.users") 10 | 11 | # logger.debug(f"GlobalUsers, FileDB: {_db}") 12 | 13 | 14 | async def get_user(username: str) -> Optional[schema.User]: 15 | from . import _db 16 | 17 | for i in _db._index["users"]: 18 | if _db._index["users"][i].username == username: 19 | return _db._index["users"][i] 20 | return None 21 | 22 | 23 | async def get_user_uuid(uuid: uuid.UUID) -> Optional[schema.User]: 24 | from . import _db 25 | 26 | if uuid in _db._index["users"]: 27 | return _db._index["users"][uuid] 28 | 29 | 30 | async def get_user_uniq_field(base: Type[schema.auth.AuthBase.AuthModel], field: Hashable) -> schema.User: 31 | from . import _db 32 | 33 | for i in _db._index["users"]: 34 | if ( 35 | type(_db._index["users"][i].auth_source) == base 36 | and _db._index["users"][i].auth_source.get_uniq_field() == field 37 | ): 38 | return _db._index["users"][i] 39 | return None 40 | 41 | 42 | async def get_all_users() -> Dict[uuid.UUID, schema.User]: 43 | from . import _db 44 | 45 | return _db._db["users"] 46 | 47 | 48 | async def check_user(username: str) -> bool: 49 | from . import _db 50 | 51 | for i in _db._index["users"]: 52 | if _db._index["users"][i].username == username: 53 | return True 54 | return False 55 | 56 | 57 | async def check_user_uuid(uuid: uuid.UUID) -> bool: 58 | from . import _db 59 | 60 | return uuid in _db._index["users"] 61 | 62 | 63 | async def check_user_uniq_field(base: Type[schema.auth.AuthBase.AuthModel], field: Hashable) -> bool: 64 | from . import _db 65 | 66 | for i in _db._index["users"]: 67 | if ( 68 | type(_db._index["users"][i].auth_source) == base 69 | and _db._index["users"][i].auth_source.get_uniq_field() == field 70 | ): 71 | return True 72 | return False 73 | 74 | 75 | async def insert_user(auth: schema.auth.TYPING_AUTH): 76 | from . import _db 77 | 78 | # WTF: SHITCODE or not.... :thonk: 79 | user = schema.User(auth_source=auth) 80 | _db._db["users"][user.user_id] = user 81 | _db._index["users"][user.user_id] = user 82 | return user 83 | 84 | 85 | """ 86 | async def insert_oauth_user(oauth_id: int, username: str, country: str): 87 | from . import _db 88 | 89 | # WTF: SHITCODE 90 | user = schema.User( 91 | username=username, 92 | password_hash=None, 93 | country=country, 94 | oauth_id=oauth_id, 95 | ) 96 | _db._db["users"][user.user_id] = user 97 | _db._index["users"][user.user_id] = user 98 | return user 99 | """ 100 | 101 | 102 | async def update_user_admin(user_id: uuid.UUID, new_user: schema.User): 103 | from . import _db 104 | 105 | user: schema.User = _db._index["users"][user_id] 106 | logger.debug(f"Update user {user} to {new_user}") 107 | 108 | update_entry( 109 | user, 110 | new_user.dict( 111 | exclude={ 112 | "user_id", 113 | "password_hash", 114 | "score", 115 | "solved_tasks", 116 | "oauth_id", 117 | } 118 | ), 119 | ) 120 | # user.parse_obj(new_user) 121 | logger.debug(f"Resulting user={user}") 122 | return user 123 | 124 | 125 | async def delete_user(user: schema.User): 126 | from . import _db 127 | 128 | del _db._db["users"][user.user_id] 129 | del _db._index["users"][user.user_id] 130 | -------------------------------------------------------------------------------- /docs/release-notes.md: -------------------------------------------------------------------------------- 1 | # Release notes 2 | 3 | ## Latest version 4 | 5 | ## 0.6.3a0 6 | 7 | - Added: 8 | - Forensic category to front 9 | - Fake `TokenAuth` auth way for usage of `_fake_admin_user` even if `SimpleAuth` is disabled. It is always active auth way 10 | - `unhide` CLI command. `unhide` unhides all tasks 11 | - Better handling `TelegramAuth` while generation firstblood message 12 | - `rev` alias for `binary` category 13 | - `api_admin_user_recalc_score` endpoint 14 | - `api_admin_recalc_tasks` endpoint 15 | - Solve statistics per task in scoreboard 16 | - Changed: 17 | - Colorscheme a little 18 | - Splitted and somewhere refactored CLI interface to many small modules, moved it inside package 19 | - Migration to my (`https://github.com/Rubikoid/beanie.git@encoder-fix`) fork of beanie, because of broken upstream and unclear PR [beanie!785](https://github.com/roman-right/beanie/pull/785) status 20 | - Refactored `api_scoreboard_get_internal*`, removed copypaste 21 | - Migration from nginx to caddy for serving static files - nginx broken for no reason, so migration to caddy was the simplest fix 22 | - Heavily improved `prepare_tasks` CLI cmd 23 | - Improved sorting scoreboard, make it more stable 24 | - `api_detele_everything` is a little safer now 25 | - Depricated: 26 | - NGINX as static files proxy 27 | - Fixed: 28 | - Added few missed modules to logger 29 | - Timezone while formatting time before display it in scoreboard. 30 | 31 | ## 0.6.2a2 32 | 33 | - Fixed: 34 | - Chaotic point changes in scoreboard on flag submit 35 | 36 | ## 0.6.2a1 37 | 38 | - Fixed: 39 | - Few fixes 40 | 41 | ## 0.6.2a0 42 | 43 | - Added: 44 | - Ability to auth using X-Auth-Token instead of cookie 45 | - Flag submission tests 46 | - Beanie ODM to mongodb instead of cringe file(pickle)db 47 | - MongoDB as DB in docker-compose.yml 48 | - More documentation about auth ways 49 | - Changed: 50 | - Migrated to newest pydantic/fastapi verison (pydantic v2, yes) 51 | - Refactor many things, mainly for typing or making ruff happy. 52 | - Refactor logging system 53 | - Some strings text sanitization 54 | - Refactor some tests 55 | - Use typing.Annotation for fastapi dependencies 56 | - Rename some OAUTH settings to make it better-looking 57 | - More documentation fixes 58 | - Fixes: 59 | - Some optimization in jinja formatting 60 | - Optimize scoreboard generation 61 | 62 | ## 0.6.1 63 | 64 | - Added: 65 | - Docs. 66 | - Notifications about task solves in websockets (only for admin right now) 67 | - Admin `cleanup_db` endpoint 68 | - Simple predef CLI interface for API. 69 | - Changed: 70 | - Version enumeration: removed litera `a` before version. 71 | - pyproject.toml refactor 72 | - Add more ways to pass [`admin_checker`](https://github.com/kksctf/yatb/blob/master/app/api/admin/__init__.py#L13) dep: user in cookies, token in header, token in query 73 | - Some strings sanitization 74 | 75 | ## a0.6.0 76 | 77 | - Added: 78 | - Extended check for default tokens/keys in production mode 79 | - Ressurect mode for DB during save, if docker created folder named `file.db` istead of normal file 80 | - User delete enpoint in admin API. 81 | - User password change in admin API. 82 | - Extended validation for users in SimpleAuth: username len should be in \[2,32\], pw len should be bigger than 8 83 | - Changed: 84 | - Global rework on the mechanism the models are exposed to admin/public API. 85 | - Bumped `reqirements.txt` 86 | - `reqirements.txt` and `reqirements-dev.txt` splitted to two separate files. Now for getting dev-env you have to install both 87 | - Python version bumped to 3.10 88 | - Fixed: 89 | - Tests 90 | 91 | 109 | -------------------------------------------------------------------------------- /app/config.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import subprocess 3 | from pathlib import Path 4 | from typing import Self 5 | 6 | from pydantic import MongoDsn, model_validator 7 | from pydantic_settings import BaseSettings, SettingsConfigDict 8 | 9 | _DEFAULT_TOKEN = "default_token_CHANGE_ME" # noqa: S105 # intended 10 | 11 | 12 | class DefaultTokenError(ValueError): 13 | pass 14 | 15 | 16 | class Settings(BaseSettings): 17 | DEBUG: bool = False 18 | PROFILING: bool = False 19 | 20 | TOKEN_PATH: str = "/api/users/login" 21 | 22 | # bot token for notifications 23 | BOT_TOKEN: str | None = None 24 | CHAT_ID: int | None = None 25 | 26 | # event time. should be in UTC 27 | EVENT_START_TIME: datetime.datetime = datetime.datetime(1077, 12, 12, 9, 0, tzinfo=datetime.UTC) 28 | EVENT_END_TIME: datetime.datetime = datetime.datetime(2077, 12, 13, 9, 0, tzinfo=datetime.UTC) 29 | 30 | # database name 31 | DB_NAME: str = "yatb" 32 | MONGO: MongoDsn = "mongodb://root:root@127.0.0.1:27017" # type: ignore 33 | 34 | # JWT settings 35 | JWT_SECRET_KEY: str = _DEFAULT_TOKEN 36 | JWT_ALGORITHM: str = "HS256" 37 | JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 2 # two days 38 | FLAG_SIGN_KEY: str = _DEFAULT_TOKEN 39 | 40 | # rename docs. Why? Idk, but you maybe want this 41 | FASTAPI_DOCS_URL: str = "/docs" 42 | FASTAPI_REDOC_URL: str = "/redoc" 43 | FASTAPI_OPENAPI_URL: str = "/openapi.json" 44 | 45 | # if you enable metrics - you must change that URL, metrics can expose some sensitive info 46 | MONITORING_URL: str = "/metrics" 47 | 48 | # version magic 49 | VERSION: str = "" 50 | COMMIT: str | None = None 51 | 52 | FLAG_BASE: str = "kks" 53 | CTF_NAME: str = "YATB-dev" 54 | 55 | API_TOKEN: str = _DEFAULT_TOKEN 56 | WS_API_TOKEN: str = _DEFAULT_TOKEN 57 | 58 | ENABLED_AUTH_WAYS: list[str] = [ # noqa: RUF012 59 | "SimpleAuth", 60 | "TelegramAuth", 61 | "CTFTimeOAuth", 62 | "GithubOAuth", 63 | "DiscordOAuth", 64 | ] 65 | 66 | @model_validator(mode="after") 67 | def check_non_default_tokens(self) -> Self: 68 | if self.DEBUG: 69 | return self 70 | 71 | token_check_list = ["JWT_SECRET_KEY", "FLAG_SIGN_KEY", "API_TOKEN", "WS_API_TOKEN"] 72 | for token_name in token_check_list: 73 | if getattr(self, token_name) == _DEFAULT_TOKEN: 74 | raise DefaultTokenError(f"Field '{token_name}' have default token value") 75 | 76 | return self 77 | 78 | @model_validator(mode="after") 79 | def __version_solver__(self) -> Self: 80 | if (Path() / ".git").exists(): 81 | self.VERSION += subprocess.check_output(["git", "rev-parse", "HEAD"]).decode()[:8] # noqa: S607, S603 82 | self.VERSION += ( 83 | "-Modified" 84 | if len(subprocess.check_output(["git", "status", "--porcelain"])) > 0 # noqa: S603, S607 85 | else "" 86 | ) 87 | else: 88 | self.VERSION += "0.6.3a0" 89 | if self.COMMIT: 90 | self.VERSION += f"-{self.COMMIT[:8]}" 91 | 92 | if self.DEBUG: 93 | self.VERSION += "-dev" 94 | else: 95 | self.VERSION += "-prod" 96 | 97 | return self 98 | 99 | model_config = SettingsConfigDict( 100 | # env_prefix="YATB_", 101 | env_file="yatb.env", 102 | env_file_encoding="utf-8", 103 | extra="allow", 104 | ) 105 | 106 | 107 | settings = Settings() 108 | 109 | # ==== CLASSES FOR MD RENDERER ==== 110 | MD_CLASSES_TASKS = { 111 | "p": "card-text", 112 | } 113 | 114 | MD_ATTRS_TASKS = { 115 | "a": { 116 | "target": "_blank", 117 | "rel": "noopener noreferrer", 118 | # "class": "btn btn-outline-primary btn-sm col-auto m-1 flex-fill" 119 | } 120 | } 121 | # ==== CLASSES FOR MD RENDERER ==== 122 | -------------------------------------------------------------------------------- /app/auth.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import UTC, datetime, timedelta 3 | from typing import Annotated 4 | 5 | from fastapi import Depends, HTTPException, Request, status 6 | from fastapi.openapi.models import OAuthFlows as OAuthFlowsModel 7 | from fastapi.security import OAuth2 8 | from fastapi.security.utils import get_authorization_scheme_param 9 | from jose import JWTError, jwt 10 | 11 | from . import schema 12 | from .config import settings 13 | from .db.beanie import UserDB 14 | from .utils.log_helper import get_logger 15 | 16 | logger = get_logger("auth") 17 | 18 | 19 | class OAuth2PasswordBearerWithCookie(OAuth2): 20 | def __init__( 21 | self, 22 | *, 23 | scheme_name: str | None = None, 24 | scopes: dict | None = None, 25 | description: str | None = None, 26 | auto_error: bool = True, 27 | ) -> None: 28 | scopes = scopes or {} 29 | 30 | flows = OAuthFlowsModel() # password={"tokenUrl": tokenUrl, "scopes": scopes} 31 | super().__init__(flows=flows, scheme_name=scheme_name, description=description, auto_error=auto_error) 32 | 33 | async def __call__(self, request: Request) -> str: 34 | authorization_cookie = request.cookies.get("access_token", None) 35 | authorization_header = request.headers.get("X-Auth-Token", None) 36 | 37 | if not authorization_cookie and not authorization_header: 38 | raise HTTPException( 39 | status_code=status.HTTP_401_UNAUTHORIZED, 40 | detail="No cookie or header", 41 | headers={"WWW-Authenticate": "Bearer"}, 42 | ) 43 | 44 | scheme, param = get_authorization_scheme_param(authorization_header or authorization_cookie) 45 | if scheme.lower() != "bearer": 46 | raise HTTPException( 47 | status_code=status.HTTP_401_UNAUTHORIZED, 48 | detail="Not authenticated", 49 | headers={"WWW-Authenticate": "Bearer"}, 50 | ) 51 | 52 | return param 53 | 54 | 55 | oauth2_scheme = OAuth2PasswordBearerWithCookie() 56 | 57 | 58 | def create_access_token(data: dict, expires_delta: timedelta = timedelta(minutes=15)) -> str: 59 | to_encode = data.copy() 60 | 61 | expire = datetime.now(tz=UTC) + expires_delta 62 | to_encode.update({"exp": expire}) 63 | 64 | return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) 65 | 66 | 67 | def create_user_token(user: schema.User) -> str: 68 | access_token_expires = timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES) 69 | return create_access_token( 70 | data={"user_id": str(user.user_id)}, 71 | expires_delta=access_token_expires, 72 | ) 73 | 74 | 75 | async def get_current_user(token: str = Depends(oauth2_scheme)) -> UserDB: 76 | credentials_exception = HTTPException( 77 | status_code=status.HTTP_401_UNAUTHORIZED, 78 | detail="Could not validate credentials", 79 | headers={"WWW-Authenticate": "Bearer"}, 80 | ) 81 | 82 | user_id: str | None = None 83 | try: 84 | payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) # no "alg:none" 85 | user_id = payload.get("user_id") 86 | if user_id is None: 87 | raise credentials_exception 88 | except JWTError as ex: 89 | raise credentials_exception from ex 90 | 91 | user = await UserDB.find_by_user_uuid(uuid.UUID(user_id)) 92 | if user is None: 93 | raise credentials_exception 94 | 95 | return user 96 | 97 | 98 | async def get_current_user_safe(request: Request) -> UserDB | None: 99 | user = None 100 | try: 101 | user = await get_current_user(await oauth2_scheme(request)) 102 | except HTTPException: 103 | user = None 104 | 105 | return user 106 | 107 | 108 | CURR_USER = Annotated[UserDB, Depends(get_current_user)] 109 | CURR_USER_SAFE = Annotated[UserDB | None, Depends(get_current_user_safe)] 110 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "YATB" 3 | version = "0.6.3a0" 4 | 5 | description = "Yet another fast and furious jeopardy-CTF taskboard" 6 | 7 | authors = [ 8 | { name = "Rubikoid", email = "rubikoid@kksctf.ru" }, 9 | { name = "Maxim Anfinogenov", email = "anfinogenov@kksctf.ru" }, 10 | ] 11 | 12 | license = "Apache-2.0" 13 | 14 | readme = "README.md" 15 | 16 | requires-python = ">=3.11" 17 | 18 | keywords = ["ctf", "jeopardy", "ctf-platform", "fastapi"] 19 | 20 | classifiers = ["Topic :: Software Development"] 21 | 22 | [project.urls] 23 | homepage = "https://github.com/kksctf/yatb" 24 | repository = "https://github.com/kksctf/yatb" 25 | documentation = "https://github.com/kksctf/yatb" 26 | 27 | # black config enabled. 28 | [tool.black] 29 | line-length = 120 30 | target_version = ['py311'] 31 | include = '\.pyi?$' 32 | exclude = ''' 33 | 34 | ( 35 | /( 36 | \.eggs # exclude a few common directories in the 37 | | \.git # root of the project 38 | | \.hg 39 | | \.mypy_cache 40 | | \.tox 41 | | \.venv 42 | | _build 43 | | buck-out 44 | | build 45 | | dist 46 | )/ 47 | ) 48 | ''' 49 | 50 | [tool.ruff] 51 | line-length = 120 52 | 53 | # no thanks, i can fix it myself 54 | fix = false 55 | 56 | # python 3.10 target? 57 | target-version = "py311" 58 | 59 | task-tags = ["TODO", "FIXME", "WTF", "XXX"] 60 | 61 | # rules... 62 | select = ["ALL"] 63 | ignore = [ 64 | "ANN101", # | ruff? | Missing type annotation for `self` in method # non sense 65 | "B008", # | ruff? | Do not perform function call `...` in argument defaults # fastapi DI... 66 | "D100", # | pydocstyle | Missing docstring in public module # meh 67 | "D101", # | pydocstyle | Missing docstring in public class # meh 68 | "D102", # | pydocstyle | Missing docstring in public method # meh 69 | "D103", # | pydocstyle | Missing docstring in public function # meh 70 | "D104", # | pydocstyle | Missing docstring in public package # meh 71 | "D105", # | pydocstyle | Missing docstring in magic method # meh 72 | "D106", # | pydocstyle | Missing docstring in public nested class # meh 73 | "D107", # | pydocstyle | Missing docstring in `__init__` # meh 74 | "D200", # | pydocstyle | One-line docstring should fit on one line # don't like it 75 | "D202", # | pydocstyle | No blank lines allowed after function docstring (found 1) # don't like it 76 | "D203", # | pydocstyle | 1 blank line required before class docstring # don't like it 77 | "D205", # | pydocstyle | 1 blank line required between summary line and description # don't like it 78 | "EM102", # | ruff? | Exception must not use an f-string literal, assign to variable first # i care, but not this proj 79 | "ERA001", # | ruff? | commented out code # i know. and what? 80 | "F401", # | pyflakes | %r imported but unused # pylance cover it 81 | "TID252", # | flake8-tidy-imports | Relative imports are banned # no. 82 | "TRY003", # | ruff? | Avoid specifying long messages outside the exception class # i care, but not this proj 83 | "G004", # | ruff? | Logging statement uses f-string # i care, but not this proj 84 | ] 85 | 86 | 87 | [tool.pytest.ini_options] 88 | addopts = "--pyargs app --cov=app" 89 | env = ["YATB_DEBUG=True", "ENABLE_METRICS=false"] 90 | filterwarnings = ["ignore::DeprecationWarning"] 91 | -------------------------------------------------------------------------------- /app/api/api_users.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from collections.abc import Sequence 3 | from typing import Iterable, TypeVar 4 | 5 | from fastapi import APIRouter, Depends, HTTPException, Request, Response, status 6 | 7 | from .. import auth, schema 8 | from ..config import settings 9 | from ..db.beanie import TaskDB, UserDB 10 | from .api_tasks import api_tasks_get 11 | 12 | router = APIRouter( 13 | prefix="/users", 14 | tags=["users"], 15 | ) 16 | 17 | _T = TypeVar("_T", schema.User, UserDB.ScoreboardProjection) 18 | 19 | 20 | def filter_scoreboard(users: Iterable[_T]) -> Sequence[_T]: 21 | ret = users 22 | 23 | if not settings.DEBUG: 24 | ret = filter(lambda x: not x.is_admin, ret) 25 | 26 | ret = sorted( 27 | ret, 28 | key=lambda i: ( 29 | -i.score, 30 | i.get_last_solve_time()[1], 31 | ), 32 | reverse=False, 33 | ) 34 | 35 | return ret 36 | 37 | 38 | async def api_scoreboard_get_internal() -> Sequence[schema.User]: 39 | users = await UserDB.get_all() 40 | 41 | return filter_scoreboard(users.values()) 42 | 43 | 44 | async def api_scoreboard_get_internal_shrinked() -> Sequence[UserDB.ScoreboardProjection]: 45 | users = await UserDB.get_all_projected(UserDB.ScoreboardProjection) 46 | 47 | return filter_scoreboard(users.values()) 48 | 49 | 50 | @router.get("/scoreboard") 51 | async def api_scoreboard_get() -> Sequence[schema.User.public_model]: 52 | users = await api_scoreboard_get_internal() 53 | return users # noqa: RET504 54 | 55 | 56 | @router.get("/ctftime_scoreboard") 57 | async def api_task_get_ctftime_scoreboard(*, fullScoreboard: bool = False): 58 | scoreboard = await api_scoreboard_get_internal() 59 | standings = [] 60 | tasks = None 61 | full_tasks_list = None 62 | if fullScoreboard: 63 | tasks_list = await api_tasks_get(None) # we don't need to export hidden tasks 64 | full_tasks_list = await TaskDB.get_all() 65 | tasks = [x.task_name for x in tasks_list] 66 | for i, user in enumerate(scoreboard): 67 | obj = { 68 | "pos": i + 1, 69 | "team": user.username, 70 | "score": user.score, 71 | } 72 | if fullScoreboard and full_tasks_list: 73 | obj["taskStats"] = {} 74 | for solved_task in user.solved_tasks: 75 | obj["taskStats"][full_tasks_list[solved_task].task_name] = { 76 | "points": full_tasks_list[solved_task].scoring.points, 77 | "time": user.solved_tasks[solved_task], 78 | } 79 | standings.append(obj) 80 | if fullScoreboard: 81 | return { 82 | "tasks": tasks, 83 | "standings": standings, 84 | } 85 | else: 86 | return { 87 | "standings": standings, 88 | } 89 | 90 | 91 | @router.get("/me") 92 | async def api_users_me(user: auth.CURR_USER) -> schema.User.public_model: 93 | return user 94 | 95 | 96 | @router.get("/logout") 97 | async def api_users_logout(req: Request, resp: Response, user: auth.CURR_USER) -> str: 98 | resp.delete_cookie(key="access_token") 99 | resp.status_code = status.HTTP_307_TEMPORARY_REDIRECT 100 | resp.headers["Location"] = str(req.url_for("index")) 101 | return "ok" 102 | 103 | 104 | @router.get("/{user_id}") 105 | async def api_users_get(user_id: uuid.UUID, user: auth.CURR_USER) -> schema.User.public_model: 106 | req_user = await UserDB.find_by_user_uuid(user_id) 107 | if not req_user: 108 | raise HTTPException( 109 | status_code=status.HTTP_404_NOT_FOUND, 110 | detail="ID not found", 111 | headers={"WWW-Authenticate": "Bearer"}, 112 | ) 113 | return req_user 114 | 115 | 116 | @router.get("/{user_id}/username") 117 | async def api_users_get_username(user_id: uuid.UUID) -> str: 118 | req_user = await UserDB.find_by_user_uuid(user_id) 119 | if not req_user: 120 | raise HTTPException( 121 | status_code=status.HTTP_404_NOT_FOUND, 122 | detail="ID not found", 123 | headers={"WWW-Authenticate": "Bearer"}, 124 | ) 125 | return req_user.username 126 | -------------------------------------------------------------------------------- /app/view/templates/admin/users_admin.jhtml: -------------------------------------------------------------------------------- 1 | {% extends "admin/base.jhtml" %} 2 | {% import "admin/macro.jhtml" as a_macro with context %} 3 | {% import "macro.jhtml" as macro with context %} 4 | 5 | {% block header %} 6 | {% set header_data.yatb_logo_text = "USERS ADMIN" %} 7 | {{ super() }} 8 | {% endblock %} 9 | 10 | {% block content %} 11 |
12 |
13 | {% if not selected_user %} 14 | {# {{ a_macro.generate_form( 15 | user_form_class.schema(), 16 | form_id="user_create_form", 17 | overrides = { 18 | "description": "textarea", 19 | "scoring": "class", 20 | }, 21 | attribs={ 22 | "scoring": { 23 | "classtype": ["readonly"] 24 | } 25 | }) 26 | }} #} 27 | no form ;( 28 | {% else %} 29 | {{ a_macro.generate_form( 30 | user_class.schema(), 31 | form_id="user_edit_form", 32 | values=selected_user.dict(), 33 | overrides = { }, 34 | attribs={ 35 | 'user_id': ['readonly'], 36 | 'password_hash': ['readonly'], 37 | 'score': ['readonly'], 38 | 'solved_tasks': ['readonly'], 39 | 'oauth_id': ['readonly'], 40 | }) 41 | }} 42 | {% endif %} 43 |
44 |
45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | {% for user in users_list %} 58 | 59 | 64 | 65 | 66 | 67 | 72 | 77 | 78 | {% endfor %} 79 | 80 |
UsernameProviderScoreAdminSolved tasksActions
60 | 61 | {{ user.username }} 62 | 63 | {{ user.auth_source.classtype }}{{ user.score }}{{ 'Admin' if user.is_admin else '' }} 68 | {% for solved_uuid in user.solved_tasks %} 69 | ; 70 | {% endfor %} 71 | 73 | Edit 74 | {# #} 75 | {# #} 76 |
81 |
82 | {% endblock %} 83 | 84 | {% block scripts %} 85 | {{ super() }} 86 | 96 | {% endblock %} 97 | 98 | {% block footer %} 99 | {{ super() }} 100 | {% endblock %} 101 | -------------------------------------------------------------------------------- /app/utils/log_helper.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | from typing import ClassVar 4 | 5 | from ..config import settings 6 | 7 | APP_NAME = "yatb" 8 | MODULES = [ 9 | "init", 10 | "schema", 11 | "schema.auth", 12 | "schema.scoring", 13 | "schema.user", 14 | "schema.task", 15 | "auth", 16 | "api", 17 | "api.admin", 18 | "db", 19 | "db.tasks", 20 | "db.users", 21 | "db.beanie", 22 | "view", 23 | ] 24 | 25 | 26 | def get_logger( 27 | module_name: str, 28 | *, 29 | app_name: str = APP_NAME, 30 | ) -> logging.Logger: 31 | return logging.getLogger(f"{app_name}.{module_name}") 32 | 33 | 34 | class DebugFormatter(logging.Formatter): 35 | grey = "\x1b[38;20m" 36 | green = "\x1b[32;20m" 37 | yellow = "\x1b[33;20m" 38 | red = "\x1b[31;20m" 39 | bold_red = "\x1b[31;1m" 40 | reset = "\x1b[0m" 41 | raw_format = ( 42 | "{green}{{asctime}}{r} | {level}{{levelname:<8s}}{r} | {{name}} | {{filename}}:{{lineno}} | {{message}}" 43 | ) 44 | 45 | COLORS: ClassVar[dict[int, str]] = { 46 | logging.DEBUG: grey, 47 | logging.INFO: green, 48 | logging.WARNING: yellow, 49 | logging.ERROR: red, 50 | logging.CRITICAL: bold_red, 51 | } 52 | 53 | FMTS: dict[int, logging.Formatter] = {} 54 | 55 | def get_formatter(self, levelno: int) -> logging.Formatter: 56 | if levelno not in self.FMTS: 57 | log_color = self.COLORS.get(levelno) 58 | log_fmt = self.raw_format.format( 59 | level=log_color, 60 | grey=self.grey, 61 | green=self.green, 62 | yellow=self.yellow, 63 | red=self.red, 64 | bold_red=self.bold_red, 65 | r=self.reset, 66 | ) 67 | self.FMTS[levelno] = logging.Formatter(log_fmt, style="{") 68 | 69 | return self.FMTS[levelno] 70 | 71 | def format(self, record): # noqa: A003 72 | return self.get_formatter(record.levelno).format(record) 73 | 74 | 75 | def setup_loggers( 76 | base_name: str = APP_NAME, 77 | base_folder: Path | None = None, # Path("logs"), 78 | modules: list[str] | tuple[str] = ("init",), 79 | root_format: str = "level=%(levelname)s | module=%(name)s | msg=%(message)s", 80 | module_format: str = "level=%(levelname)s | msg=%(message)s ", 81 | root_formatter: logging.Formatter | None = None, 82 | module_formatter: logging.Formatter | None = None, 83 | ) -> logging.Logger: 84 | if base_folder and not base_folder.exists(): 85 | base_folder.mkdir(parents=True) 86 | 87 | root_formatter = root_formatter or logging.Formatter(root_format) 88 | module_formatter = module_formatter or logging.Formatter(module_format) 89 | 90 | root_logger = logging.getLogger(base_name) 91 | root_logger.setLevel(logging.DEBUG) 92 | 93 | if base_folder: 94 | fh = logging.FileHandler(base_folder / f"{base_name}.log", encoding="utf-8") 95 | fh.setLevel(logging.DEBUG) 96 | fh.setFormatter(root_formatter) 97 | root_logger.addHandler(fh) 98 | 99 | ch = logging.StreamHandler() 100 | ch.setLevel(logging.INFO if not settings.DEBUG else logging.DEBUG) 101 | ch.setFormatter(root_formatter) 102 | root_logger.addHandler(ch) 103 | 104 | for module in modules: 105 | module_logger = logging.getLogger(f"{base_name}.{module}") 106 | module_logger.setLevel(logging.DEBUG) 107 | 108 | if base_folder: 109 | fh = logging.FileHandler(base_folder / f"{base_name}.{module}.log", encoding="utf-8") 110 | fh.setLevel(logging.DEBUG) 111 | fh.setFormatter(module_formatter) 112 | module_logger.addHandler(fh) 113 | 114 | return root_logger 115 | 116 | 117 | if settings.DEBUG: 118 | root_logger = setup_loggers( 119 | base_name=APP_NAME, 120 | modules=MODULES, 121 | root_formatter=DebugFormatter(), 122 | ) 123 | else: 124 | root_logger = setup_loggers( 125 | base_name=APP_NAME, 126 | base_folder=Path("logs"), 127 | modules=MODULES, 128 | root_format="level=%(levelname)s time=%(asctime)s module=%(name)s at=%(funcName)s:%(lineno)d msg='%(message)s'", 129 | module_format="level=%(levelname)s time=%(asctime)s at=%(funcName)s:%(lineno)d msg='%(message)s'", 130 | ) 131 | 132 | logging.getLogger(APP_NAME).propagate = False 133 | -------------------------------------------------------------------------------- /app/api/api_tasks.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import UTC, datetime 3 | 4 | from fastapi import APIRouter, Depends, HTTPException, status 5 | 6 | from .. import auth, schema 7 | from ..config import settings 8 | from ..db.beanie import TaskDB 9 | from ..utils import metrics, tg 10 | from ..ws import ws_manager 11 | from . import logger 12 | 13 | router = APIRouter( 14 | prefix="/tasks", 15 | tags=["tasks"], 16 | ) 17 | 18 | 19 | @router.get("/") 20 | async def api_tasks_get(user: auth.CURR_USER_SAFE) -> list[schema.Task.public_model]: 21 | tasks = await TaskDB.get_all() 22 | tasks = tasks.values() 23 | tasks = filter(lambda x: x.visible_for_user(user), tasks) 24 | return list(tasks) 25 | 26 | 27 | class BRMessage(schema.EBaseModel): 28 | task_name: str 29 | user_name: str 30 | points: int 31 | is_fb: bool 32 | 33 | 34 | @router.post("/submit_flag") 35 | async def api_task_submit_flag(flag: schema.FlagForm, user: auth.CURR_USER) -> uuid.UUID: 36 | if datetime.now(tz=UTC) < settings.EVENT_START_TIME: 37 | raise HTTPException( 38 | status_code=status.HTTP_425_TOO_EARLY, 39 | detail="CTF has not started yet", 40 | ) 41 | 42 | task = await TaskDB.find_by_flag(flag.flag, user) 43 | if task: 44 | logger.info(f"{user.short_desc()} state=found task with flag flag={flag.flag}, task={task.short_desc()}.") 45 | else: 46 | logger.info(f"{user.short_desc()} state=not_found task with flag={flag.flag}") 47 | metrics.bad_solves_per_user.labels(user_id=user.user_id, username=user.username).inc() 48 | 49 | if not task or not task.visible_for_user(user): 50 | if task and not task.visible_for_user(user): 51 | logger.warning(f"Кто-то {user} попытался решить хидден таск {task}") 52 | raise HTTPException( 53 | status_code=status.HTTP_404_NOT_FOUND, 54 | detail="Bad flag", 55 | ) 56 | raise HTTPException( 57 | status_code=status.HTTP_404_NOT_FOUND, 58 | detail="Bad flag", 59 | ) 60 | 61 | if task.task_id in user.solved_tasks or user.user_id in task.pwned_by: 62 | _task_yes_user_not = task.task_id in user.solved_tasks and user.user_id not in task.pwned_by 63 | _user_yes_task_not = task.task_id not in user.solved_tasks and user.user_id in task.pwned_by 64 | if _task_yes_user_not or _user_yes_task_not: 65 | logger.warning( 66 | f"Wtf, user and task misreferenced!!! {task} {user} {_task_yes_user_not = } {_user_yes_task_not = }" 67 | ) 68 | if _task_yes_user_not: 69 | # user.solved_tasks.remove(task.task_id) 70 | pass 71 | if _user_yes_task_not: 72 | # task.pwned_by.remove(user.solved_tasks) 73 | pass 74 | 75 | await user.recalc_score_one() 76 | raise HTTPException( 77 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 78 | detail="пятисотОЧКА!!1
Попробуйте решить таск ещё раз.", 79 | ) 80 | raise HTTPException( 81 | status_code=status.HTTP_202_ACCEPTED, 82 | detail="You already solved this task", 83 | ) 84 | else: 85 | metrics.solves_per_task.labels(task_id=task.task_id, task_name=task.task_name).inc() 86 | metrics.solves_per_user.labels(user_id=user.user_id, username=user.username).inc() 87 | 88 | ret = await user.solve_task_bw(task) 89 | 90 | msg = BRMessage( 91 | task_name=task.task_name, 92 | user_name=user.username, 93 | points=task.scoring.points, 94 | is_fb=len(task.pwned_by) == 1, 95 | ) 96 | 97 | await ws_manager.broadcast(msg.model_dump_json()) 98 | 99 | if len(task.pwned_by) == 1: 100 | try: 101 | tg.display_fb_msg(task, user) 102 | except Exception as ex: # noqa: W0703, PIE786 103 | logger.error(f"tg_exception exception='{ex}'") 104 | 105 | return ret 106 | 107 | 108 | @router.get("/{task_id}") 109 | async def api_task_get(task_id: uuid.UUID, user: auth.CURR_USER_SAFE) -> schema.Task.public_model: 110 | task = await TaskDB.find_by_task_uuid(task_id) 111 | if not task or not task.visible_for_user(user): 112 | raise HTTPException( 113 | status_code=status.HTTP_404_NOT_FOUND, 114 | detail="No task", 115 | ) 116 | return task 117 | -------------------------------------------------------------------------------- /docs/config.md: -------------------------------------------------------------------------------- 1 | # YATB Config 2 | 3 | Setup config stored in [app/config.py](https://github.com/kksctf/yatb/blob/master/app/config.py) file 4 | 5 | ## .env 6 | 7 | Always populated from `yatb.env`, or from enviroment. 8 | 9 | ## Main settings 10 | 11 | ### Debug 12 | 13 | - `DEBUG`: enable/disable debug mode. Should be off on production 14 | - `PROFILING`: enable/disable profiling. Enables `?profile=true` query param for viewing PyInstrument output 15 | 16 | ### Event timing 17 | 18 | - `EVENT_START_TIME` / `EVENT_END_TIME`: dates of event start and end. **MUST** be in UTC and offset-aware. 19 | 20 | ### DB Data 21 | 22 | - `DB_NAME`: name of database 23 | - `MONGO`: DSN for connecting to mongodb instance 24 | 25 | ## **Must** be changed 26 | 27 | - `JWT_SECRET_KEY` - secret key for JWT cookie signing. 28 | - `FLAG_SIGN_KEY` - the same, but for dynamic flags. 29 | - `API_TOKEN` - token for accessing admin API without admin user / auth. 30 | - `WS_API_TOKEN` - token for acessing admin WS subscription API. 31 | 32 | ## CTF Customization 33 | 34 | - `FLAG_BASE` - base for flags (i.e. part before brackets), for example: `kks` for flags like `kks{example_flag}` 35 | - `CTF_NAME` - ctf name to be displayed on front 36 | 37 | ### Auth ways 38 | 39 | Note that in addition to enabling the way, you must configure the authentication method itself separately. 40 | 41 | Every other authway has separate settings variables prefix. 42 | 43 | - `ENABLED_AUTH_WAYS`: list of enabled yatb authentification ways. Defaults to: 44 | - Simple (login + password) 45 | - Telegram 46 | - CTFTime 47 | - GitHub 48 | - Discord 49 | 50 | #### Simple 51 | 52 | prefix: `AUTH_SIMPLE_` 53 | 54 | - `AUTH_SIMPLE_DEBUG_USERNAME`: username, which will be promoted to admin, **if DEBUG mode enabled** 55 | - `AUTH_SIMPLE_MIN_PASSWORD_LEN`: minimum password length 56 | - `AUTH_SIMPLE_MIN_USERNAME_LEN`: minimum username length 57 | - `AUTH_SIMPLE_MAX_USERNAME_LEN`: maximum username length 58 | 59 | #### Telegram 60 | 61 | prefix: `AUTH_TG_` 62 | 63 | - `AUTH_TG_BOT_TOKEN`: bot token 64 | - `AUTH_TG_BOT_USERNAME`: bot public username 65 | - `AUTH_TG_ADMIN_USERNAMES`: list of usernames, which will be promoted to admin 66 | - `AUTH_TG_ADMIN_UIDS`: list of admin telegram IDs, which will be promoted to admin 67 | 68 | #### OAuth 69 | 70 | This auth way is more like an underlying auth way, and is needed as an abstraction for other auth ways. 71 | 72 | Since OAuth2 does not specify the fields in which user information is returned, it is impossible to make a universal auth way. 73 | 74 | However, almost all settings that are defined for the basic OAuth are valid for all its implementations. 75 | 76 | prefix: `AUTH_OAUTH_` 77 | 78 | - `AUTH_OAUTH_ADMIN_IDS`: user IDs, which will be promoted to admin (depending on the service) 79 | - `AUTH_OAUTH_CLIENT_ID`: oauth client id 80 | - `AUTH_OAUTH_CLIENT_SECRET`: oauth client secret 81 | - `AUTH_OAUTH_ENDPOINT`: authorize endpoint 82 | - `AUTH_OAUTH_TOKEN_ENDPOINT`: token endpoint 83 | - `AUTH_OAUTH_API_ENDPOINT`: user info / some api endpoint 84 | 85 | #### OAuth: CTFTime 86 | 87 | prefix: `AUTH_CTFTIME_` 88 | 89 | - `AUTH_CTFTIME_ADMIN_IDS`: teams IDs, which users will be promoted to admin 90 | - `AUTH_CTFTIME_CLIENT_ID`: ctftime client id 91 | - `AUTH_CTFTIME_CLIENT_SECRET`: ctftime client secret 92 | 93 | #### OAuth: GitHub 94 | 95 | prefix: `AUTH_GITHUB_` 96 | 97 | - `AUTH_GITHUB_ADMIN_IDS`: user IDs, which will be promoted to admin 98 | - `AUTH_GITHUB_CLIENT_ID`: github client id 99 | - `AUTH_GITHUB_CLIENT_SECRET`: github client secret 100 | 101 | #### OAuth: Discord 102 | 103 | prefix: `AUTH_DISCORD_` 104 | 105 | - `AUTH_DISCORD_ADMIN_IDS`: user IDs, which will be promoted to admin 106 | - `AUTH_DISCORD_CLIENT_ID`: discord client id 107 | - `AUTH_DISCORD_CLIENT_SECRET`: discord client secret 108 | 109 | ## TG Notifications 110 | 111 | - `BOT_TOKEN` - token from botfather 112 | - `CHAT_ID` - chat id of chat for notifications (channel/group/PM) 113 | 114 | ## FastAPI URLs 115 | 116 | There are some params for hiding fastapi docs: 117 | 118 | - `FASTAPI_DOCS_URL` - default swagger 119 | - `FASTAPI_REDOC_URL` - redoc 120 | - `FASTAPI_OPENAPI_URL` - openapi schema 121 | 122 | ## Monitoring 123 | 124 | - `MONITORING_URL` - endpoint for prom metrics 125 | 126 | ## JWT settings 127 | 128 | - `JWT_ALGORITHM` - jwt algo 129 | - `JWT_ACCESS_TOKEN_EXPIRE_MINUTES` - jwt token lifetime 130 | 131 | ## Misc 132 | 133 | - `VERSION`: base string, used for versing string population 134 | -------------------------------------------------------------------------------- /app/test/__init__.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: S101, S106, ANN201, T201 # this is a __test file__ 2 | 3 | import typing 4 | 5 | import pytest 6 | from fastapi.testclient import TestClient 7 | from httpx import Response 8 | 9 | from .. import app, schema 10 | from ..config import settings 11 | from ..db.beanie import db 12 | 13 | settings.DB_NAME = "yatb_testing" 14 | 15 | LoginForm = schema.SimpleAuth.Form._Internal # noqa: SLF001 16 | 17 | 18 | class ClientExRaw(TestClient): 19 | def __enter__(self) -> typing.Self: 20 | super().__enter__() 21 | self.drop_db() 22 | return self 23 | 24 | def simple_register_raw(self, username: str, password: str) -> Response: 25 | return self.post( 26 | app.url_path_for("api_auth_simple_register"), 27 | json=LoginForm(username=username, password=password).model_dump(mode="json"), 28 | ) 29 | 30 | def simple_login_raw(self, username: str, password: str) -> Response: 31 | return self.post( 32 | app.url_path_for("api_auth_simple_login"), 33 | json=LoginForm(username=username, password=password).model_dump(mode="json"), 34 | ) 35 | 36 | def create_task_raw( # noqa: PLR0913 37 | self, 38 | task_name: str, 39 | category: str, 40 | scoring: schema.ScoringUnion, 41 | description: str, 42 | flag: schema.FlagUnion, 43 | ) -> Response: 44 | return self.post( 45 | app.url_path_for("api_admin_task_create"), 46 | json=schema.TaskForm( 47 | task_name=task_name, 48 | category=category, 49 | scoring=scoring, 50 | description=description, 51 | flag=flag, 52 | ).model_dump(mode="json"), 53 | ) 54 | 55 | def modify_task_raw(self, task: schema.Task) -> Response: 56 | return self.post( 57 | app.url_path_for("api_admin_task_edit", task_id=task.task_id), 58 | json=task.model_dump(mode="json"), 59 | ) 60 | 61 | def solve_task_raw(self, flag: str) -> Response: 62 | return self.post( 63 | app.url_path_for("api_task_submit_flag"), 64 | json=schema.FlagForm(flag=flag).model_dump(mode="json"), 65 | ) 66 | 67 | def get_me_raw(self) -> Response: 68 | return self.get(app.url_path_for("api_users_me")) 69 | 70 | def drop_db(self) -> None: 71 | with self._portal_factory() as portal: 72 | print("Drop DB") 73 | portal.call(db.reset_db) 74 | 75 | 76 | class ClientEx(ClientExRaw): 77 | # def simple_register(self, username: str, password: str) -> schema.User: 78 | # resp = self.simple_register_raw(username=username, password=password) 79 | # resp.raise_for_status() 80 | # return schema.User.public_model().model_validate(resp.json()) 81 | 82 | # def simple_login(self, username: str, password: str) -> schema.User: 83 | # resp = self.simple_login_raw(username=username, password=password) 84 | # resp.raise_for_status() 85 | # return schema.User.public_model().model_validate(resp.json()) 86 | 87 | def create_task( # noqa: PLR0913 88 | self, 89 | task_name: str, 90 | category: str, 91 | scoring: schema.ScoringUnion, 92 | description: str, 93 | flag: schema.FlagUnion, 94 | ) -> schema.Task: 95 | resp = self.create_task_raw( 96 | task_name=task_name, 97 | category=category, 98 | scoring=scoring, 99 | description=description, 100 | flag=flag, 101 | ) 102 | resp.raise_for_status() 103 | 104 | return schema.Task.admin_model.model_validate(resp.json()) 105 | 106 | def modify_task(self, task: schema.Task) -> schema.Task: 107 | resp = self.modify_task_raw(task=task) 108 | resp.raise_for_status() 109 | 110 | return schema.Task.admin_model.model_validate(resp.json()) 111 | 112 | def get_me(self) -> schema.User: 113 | resp = self.get_me_raw() 114 | resp.raise_for_status() 115 | 116 | return schema.User.public_model.model_validate(resp.json()) 117 | 118 | 119 | @pytest.fixture() 120 | def client(request): 121 | print("Client init") 122 | client = ClientEx(app).__enter__() 123 | 124 | yield client 125 | 126 | print("Client shutdown") 127 | client.__exit__() 128 | 129 | 130 | # from . import test_auth # noqa 131 | # from . import test_main # noqa 132 | -------------------------------------------------------------------------------- /app/view/admin/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import uuid 3 | 4 | from fastapi import ( 5 | Cookie, 6 | Depends, 7 | FastAPI, 8 | HTTPException, 9 | Query, 10 | Request, 11 | Response, 12 | WebSocket, 13 | WebSocketDisconnect, 14 | WebSocketException, 15 | status, 16 | ) 17 | from fastapi.routing import APIRouter 18 | 19 | from ... import auth, config, schema 20 | from ...api import api_tasks as api_tasks 21 | from ...api import api_users as api_users 22 | from ...api.admin import admin_checker 23 | from ...api.admin import admin_tasks as api_admin_tasks 24 | from ...api.admin import admin_users as api_admin_users 25 | from ...utils.log_helper import get_logger 26 | from ...ws import ws_manager 27 | 28 | logger = get_logger("view") 29 | 30 | from .. import response_generator # noqa 31 | 32 | router = APIRouter( 33 | prefix="/admin", 34 | tags=["admin_view"], 35 | ) 36 | 37 | 38 | @router.get("/") 39 | async def admin_index(req: Request, resp: Response, user: schema.User = Depends(admin_checker)): 40 | return await response_generator( 41 | req, 42 | "admin/index.jhtml", 43 | { 44 | "request": req, 45 | "curr_user": user, 46 | }, 47 | ignore_admin=False, 48 | ) 49 | 50 | 51 | @router.get("/tasks") 52 | async def admin_tasks(req: Request, resp: Response, user: schema.User = Depends(admin_checker)): 53 | tasks_list = await api_tasks.api_tasks_get(user) 54 | return await response_generator( 55 | req, 56 | "admin/tasks_admin.jhtml", 57 | { 58 | "request": req, 59 | "curr_user": user, 60 | "task_class": schema.Task, 61 | "task_form_class": schema.TaskForm, 62 | "tasks_list": tasks_list, 63 | }, 64 | ignore_admin=False, 65 | ) 66 | 67 | 68 | @router.get("/task/{task_id}") 69 | async def admin_task_get(req: Request, resp: Response, task_id: uuid.UUID, user: schema.User = Depends(admin_checker)): 70 | tasks_list = await api_tasks.api_tasks_get(user) 71 | selected_task = await api_tasks.api_task_get(task_id, user) 72 | return await response_generator( 73 | req, 74 | "admin/tasks_admin.jhtml", 75 | { 76 | "request": req, 77 | "curr_user": user, 78 | "task_class": schema.Task, 79 | "task_form_class": schema.TaskForm, 80 | "tasks_list": tasks_list, 81 | "selected_task": selected_task, 82 | }, 83 | ignore_admin=False, 84 | ) 85 | 86 | 87 | @router.get("/users") 88 | async def admin_users(req: Request, resp: Response, user: schema.User = Depends(admin_checker)): 89 | users_dict = await api_admin_users.api_admin_users_internal() 90 | return await response_generator( 91 | req, 92 | "admin/users_admin.jhtml", 93 | { 94 | "request": req, 95 | "curr_user": user, 96 | "user_class": schema.User, 97 | # "user_form_class": schema.UserForm, 98 | "users_list": users_dict.values(), 99 | }, 100 | ignore_admin=False, 101 | ) 102 | 103 | 104 | @router.get("/user/{user_id}") 105 | async def admin_user_get(req: Request, resp: Response, user_id: uuid.UUID, user: schema.User = Depends(admin_checker)): 106 | users_dict = await api_admin_users.api_admin_users_internal() 107 | selected_user = await api_admin_users.api_admin_user_get_internal(user_id) 108 | return await response_generator( 109 | req, 110 | "admin/users_admin.jhtml", 111 | { 112 | "request": req, 113 | "curr_user": user, 114 | "user_class": schema.User, 115 | # "user_form_class": schema.UserForm, 116 | "users_list": users_dict.values(), 117 | "selected_user": selected_user, 118 | }, 119 | ignore_admin=False, 120 | ) 121 | 122 | 123 | async def get_token( 124 | websocket: WebSocket, 125 | token: str | None = Query(default=None), 126 | ): 127 | if token and token == config.settings.WS_API_TOKEN: 128 | return token 129 | 130 | raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION) 131 | 132 | 133 | @router.websocket("/ws") 134 | async def websocker_ep( 135 | websocket: WebSocket, 136 | token: str = Depends(get_token), 137 | ): 138 | logger.info(f"{token= } connected to WS") 139 | await ws_manager.connect(websocket) 140 | try: 141 | await websocket.send_json({"ping": "ping"}) 142 | while True: 143 | await websocket.receive_text() 144 | except WebSocketDisconnect: 145 | ws_manager.disconnect(websocket) 146 | -------------------------------------------------------------------------------- /app/test/test_tasks_api.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: S101, S106, ANN201 # this is a __test file__ 2 | 3 | from fastapi import status 4 | 5 | from .. import schema 6 | from . import ClientEx, app, test_auth 7 | from . import client as client_cl 8 | 9 | client = client_cl 10 | 11 | 12 | def test_task_create(client: ClientEx): 13 | test_auth.test_admin(client) 14 | 15 | resp1 = client.create_task_raw( 16 | task_name="TestTast1", 17 | category="web", 18 | scoring=schema.StaticScoring(static_points=1337), 19 | description="test_task_decription", 20 | flag=schema.StaticFlag(flag_base="kks", flag="test_task"), 21 | ) 22 | assert resp1.status_code == status.HTTP_200_OK, resp1.text 23 | 24 | resp2 = client.create_task_raw( 25 | task_name="TestTast2", 26 | category="pwn", 27 | scoring=schema.StaticScoring(static_points=1338), 28 | description="second_test_task_description", 29 | flag=schema.StaticFlag(flag_base="kks", flag="other_test_task"), 30 | ) 31 | assert resp2.status_code == status.HTTP_200_OK, resp2.text 32 | 33 | resp1_1 = client.get(app.url_path_for("api_admin_task_get", task_id=resp1.json()["task_id"])) 34 | assert resp1_1.status_code == status.HTTP_200_OK, resp1_1.text 35 | assert resp1_1.json()["task_id"] == resp1.json()["task_id"], resp1.json() 36 | assert resp1_1.json()["task_name"] == "TestTast1", resp1.json() 37 | assert resp1_1.json()["category"] == "web", resp1.json() 38 | assert resp1_1.json()["description"] == "test_task_decription", resp1.json() 39 | 40 | resp3 = client.get(app.url_path_for("api_admin_tasks")) 41 | assert resp3.status_code == status.HTTP_200_OK, resp3.text 42 | assert resp1.json()["task_id"] in resp3.json(), resp3.json() 43 | assert resp2.json()["task_id"] in resp3.json(), resp3.json() 44 | 45 | resp4 = client.get(app.url_path_for("api_tasks_get")) 46 | assert resp4.status_code == status.HTTP_200_OK, resp4.text 47 | assert len(resp4.json()) == 2, resp4.json() # noqa: PLR2004 48 | 49 | for task in resp3.json().values(): 50 | task_obj = schema.Task(**task) 51 | task_obj.hidden = False 52 | resp_show = client.post( 53 | app.url_path_for("api_admin_task_edit", task_id=task_obj.task_id), 54 | json=task_obj.model_dump(mode="json"), 55 | ) 56 | assert resp_show.status_code == status.HTTP_200_OK, f"{task['task_name']} {resp_show.text}" 57 | 58 | resp5 = client.get(app.url_path_for("api_tasks_get")) 59 | assert resp5.status_code == status.HTTP_200_OK, resp5.text 60 | assert "flag" not in resp5.json()[0] 61 | assert "flag" not in resp5.json()[1] 62 | assert "flag" not in resp5.text 63 | 64 | assert resp5.json()[0]["task_id"] in [resp1.json()["task_id"], resp2.json()["task_id"]], resp4.json() 65 | assert resp5.json()[1]["task_id"] in [resp1.json()["task_id"], resp2.json()["task_id"]], resp4.json() 66 | 67 | 68 | def test_task_solve(client: ClientEx): 69 | client.simple_register_raw(username="Rubikoid", password="123") 70 | 71 | tasks: dict[int, schema.Task] = {} # fake array ;) 72 | tasks[0] = client.create_task( 73 | task_name="TestTast1", 74 | category="web", 75 | scoring=schema.StaticScoring(static_points=1337), 76 | description="test_task_decription", 77 | flag=schema.StaticFlag(flag_base="kks", flag="test_task"), 78 | ) 79 | tasks[1] = client.create_task( 80 | task_name="TestTast2", 81 | category="pwn", 82 | scoring=schema.StaticScoring(static_points=1338), 83 | description="second_test_task_description", 84 | flag=schema.StaticFlag(flag_base="kks", flag="other_test_task"), 85 | ) 86 | tasks[2] = client.create_task( 87 | task_name="TestTast2", 88 | category="pwn", 89 | scoring=schema.StaticScoring(static_points=1339), 90 | description="second_test_task_description", 91 | flag=schema.StaticFlag(flag_base="kks", flag="more_other_test_task"), 92 | ) 93 | 94 | for i, task in tasks.items(): 95 | task.hidden = False 96 | tasks[i] = client.modify_task(task) 97 | task = tasks[i] 98 | assert not task.hidden, f"{task = }" 99 | 100 | client.simple_register_raw(username="Rubikoid_user", password="123") 101 | 102 | resp1 = client.solve_task_raw("test_task") 103 | assert resp1.status_code == status.HTTP_200_OK, resp1.text 104 | assert resp1.text.replace('"', "") == str(tasks[0].task_id), resp1.text 105 | 106 | resp2 = client.solve_task_raw("kks{other_test_task}") 107 | assert resp2.status_code == status.HTTP_200_OK, resp2.text 108 | assert resp2.text.replace('"', "") == str(tasks[1].task_id), resp2.text 109 | 110 | resp3 = client.solve_task_raw("more_other_test_task}") 111 | assert resp3.status_code == status.HTTP_200_OK, resp3.text 112 | assert resp3.text.replace('"', "") == str(tasks[2].task_id), resp3.text 113 | 114 | me = client.get_me() 115 | assert me.score == sum(task.scoring.points for task in tasks.values()), f"{me = }" 116 | -------------------------------------------------------------------------------- /app/schema/auth/tg.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import hashlib 3 | import hmac 4 | from collections.abc import Callable 5 | from typing import ClassVar, Literal, Self 6 | 7 | from fastapi import HTTPException, Query, Request, Response, status 8 | from pydantic import field_validator, model_validator 9 | from pydantic_settings import SettingsConfigDict 10 | 11 | from ...utils.log_helper import get_logger 12 | from .auth_base import AuthBase 13 | 14 | logger = get_logger("schema.auth") 15 | 16 | 17 | class TelegramAuth(AuthBase): 18 | class AuthModel(AuthBase.AuthModel): 19 | __admin_only_fields__: ClassVar = { 20 | "tg_id", 21 | "tg_username", 22 | "tg_first_name", 23 | "tg_last_name", 24 | } 25 | classtype: Literal["TelegramAuth"] = "TelegramAuth" 26 | 27 | tg_id: int 28 | tg_username: str | None = None 29 | tg_first_name: str 30 | tg_last_name: str | None = None 31 | 32 | def is_admin(self) -> bool: 33 | is_admin: bool = False 34 | 35 | if self.tg_username and self.tg_username.lower() in TelegramAuth.auth_settings.ADMIN_USERNAMES: 36 | is_admin = True 37 | if self.tg_id in TelegramAuth.auth_settings.ADMIN_UIDS: 38 | is_admin = True 39 | 40 | return is_admin 41 | 42 | @classmethod 43 | def get_uniq_field_name(cls: type[Self]) -> str: 44 | return "tg_id" 45 | 46 | def generate_username(self) -> str: 47 | return self.tg_first_name + (" " + self.tg_last_name if self.tg_last_name else "") 48 | 49 | class Form(AuthBase.Form): 50 | id: int = Query(...) 51 | 52 | first_name: str = Query(...) 53 | 54 | last_name: str | None = Query(None) 55 | username: str | None = Query(None) 56 | photo_url: str | None = Query(None) 57 | 58 | auth_date: int = Query(...) 59 | 60 | hash: str = Query(...) 61 | 62 | @field_validator("auth_date", mode="after") 63 | @classmethod 64 | def check_date(cls: type[Self], date: int) -> int: 65 | rdate = datetime.datetime.fromtimestamp(date, tz=datetime.UTC) 66 | 67 | if (datetime.datetime.now(tz=datetime.UTC) - rdate).total_seconds() > 60 * 2: 68 | raise HTTPException( 69 | status_code=status.HTTP_400_BAD_REQUEST, 70 | detail="Bad date", 71 | ) 72 | 73 | return date 74 | 75 | @model_validator(mode="after") 76 | def check_hash(self) -> Self: # noqa: E0213, N805 77 | bot_sha = hashlib.sha256(TelegramAuth.auth_settings.BOT_TOKEN.encode()).digest() 78 | 79 | hash_check_string = "\n".join( 80 | f"{i}={attr_value}" 81 | for i in sorted( 82 | [ 83 | "id", 84 | "first_name", 85 | "last_name", 86 | "username", 87 | "photo_url", 88 | "auth_date", 89 | ], 90 | ) 91 | if (attr_value := getattr(self, i)) is not None 92 | ) 93 | hash_check = hmac.new(bot_sha, hash_check_string.encode(), digestmod="sha256") 94 | if hash_check.hexdigest() != self.hash: 95 | logger.warning(f"hash check failed for {hash_check.hexdigest()} != {self.hash}, {self}") 96 | raise HTTPException( 97 | status_code=status.HTTP_400_BAD_REQUEST, 98 | detail="Bad hash", 99 | ) 100 | 101 | return self 102 | 103 | async def populate(self, req: Request, resp: Response) -> "TelegramAuth.AuthModel": 104 | return TelegramAuth.AuthModel( 105 | tg_id=self.id, 106 | tg_first_name=self.first_name, 107 | tg_last_name=self.last_name, 108 | tg_username=self.username, 109 | ) 110 | 111 | class AuthSettings(AuthBase.AuthSettings): 112 | BOT_TOKEN: str = "" 113 | BOT_USERNAME: str = "" 114 | 115 | ADMIN_USERNAMES: list[str] = [] 116 | ADMIN_UIDS: list[int] = [] 117 | 118 | model_config = SettingsConfigDict(AuthBase.AuthSettings.model_config, env_prefix="AUTH_TG_") 119 | 120 | auth_settings: ClassVar[AuthSettings] = AuthSettings() 121 | router_params: ClassVar = { 122 | "path": "/tg_callback", 123 | "name": "api_auth_tg_callback", 124 | "methods": ["GET"], 125 | } 126 | 127 | @classmethod 128 | def generate_html(cls: type[Self], url_for: Callable) -> str: 129 | return f""" 130 | 134 | """ 135 | 136 | @classmethod 137 | def generate_script(cls: type[Self], url_for: Callable) -> str: 138 | return """""" 139 | -------------------------------------------------------------------------------- /app/api/admin/admin_tasks.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import Annotated, Mapping 3 | 4 | from beanie import BulkWriter 5 | from beanie.operators import Set 6 | from fastapi import Depends, HTTPException, status 7 | 8 | from ... import schema 9 | from ...config import settings 10 | from ...db.beanie import TaskDB, UserDB 11 | from . import CURR_ADMIN, logger, router 12 | 13 | 14 | async def get_task(task_id: uuid.UUID) -> TaskDB: 15 | task = await TaskDB.find_by_task_uuid(task_id) 16 | if not task: 17 | raise HTTPException( 18 | status_code=status.HTTP_404_NOT_FOUND, 19 | detail="Task not found", 20 | ) 21 | 22 | return task 23 | 24 | 25 | CURR_TASK = Annotated[TaskDB, Depends(get_task)] 26 | 27 | 28 | @router.get("/tasks") 29 | async def api_admin_tasks(user: CURR_ADMIN) -> Mapping[uuid.UUID, schema.Task.admin_model]: 30 | all_tasks = await TaskDB.get_all() 31 | return all_tasks 32 | 33 | 34 | @router.get("/recalc_scoreboard") 35 | async def api_admin_recalc_scoreboard(user: CURR_ADMIN): 36 | await UserDB.recalc_scoreboard() 37 | return None 38 | 39 | 40 | @router.get("/recalc_tasks") 41 | async def api_admin_recalc_tasks(user: CURR_ADMIN): 42 | await TaskDB.recalc_score() 43 | return None 44 | 45 | 46 | @router.get("/unsolve_tasks") 47 | async def api_admin_unsolve_tasks(user: CURR_ADMIN) -> str: 48 | if not settings.DEBUG: 49 | logger.critical(f"Какой-то гений {user.short_desc()} ПЫТАЛСЯ сбросить таски на проде!") 50 | return "Нет." 51 | 52 | async with BulkWriter() as bw: 53 | for task in (await TaskDB.get_all()).values(): 54 | task.pwned_by.clear() 55 | await task.update(Set({str(TaskDB.pwned_by): task.pwned_by}), bulk_writer=bw) 56 | 57 | logger.info(f"{bw.operations = }") 58 | 59 | async with BulkWriter() as bw: 60 | for user in (await UserDB.get_all()).values(): 61 | user.solved_tasks.clear() 62 | await user.update(Set({str(UserDB.solved_tasks): user.solved_tasks}), bulk_writer=bw) 63 | 64 | logger.info(f"{bw.operations = }") 65 | 66 | await UserDB.recalc_scoreboard() 67 | return "ok" 68 | 69 | 70 | @router.get("/unsolve_task/{task_id}") 71 | async def api_admin_task_unsolve(task: CURR_TASK, user: CURR_ADMIN) -> schema.Task.admin_model: 72 | logger.warning(f"Unsolving task: {task.short_desc()} by {user.short_desc()}") 73 | # return await db.unsolve_task(task) 74 | raise NotImplementedError 75 | 76 | 77 | @router.post("/task") 78 | async def api_admin_task_create(new_task: schema.TaskForm, user: CURR_ADMIN) -> schema.Task.admin_model: 79 | task = await TaskDB.populate(new_task, user) 80 | logger.debug(f"New task: {new_task}, result={task}") 81 | return task 82 | 83 | 84 | @router.get("/task/delete_all") 85 | async def api_admin_task_delete_all(user: CURR_ADMIN): 86 | raise NotImplementedError 87 | 88 | # if not settings.DEBUG: # danger function! 89 | # logger.critical(f"Какой-то гений {user.short_desc()} ПЫТАЛСЯ УДАЛИТЬ таски на проде!") 90 | # return "нет." 91 | 92 | # logger.critical(f"[{user.short_desc()}] removing EVERYTHING") 93 | # tasks = await db.get_all_tasks() 94 | # for task in list(tasks.values()): 95 | # await db.remove_task(task) 96 | # return "ok, you dead." 97 | 98 | 99 | @router.get("/task/delete/{task_id}") 100 | async def api_admin_task_delete(task: CURR_TASK, user: CURR_ADMIN): 101 | raise NotImplementedError 102 | 103 | # await db.remove_task(task) 104 | # logger.warning(f"[{user.short_desc()}] removing task {task}") 105 | # return "ok" 106 | 107 | 108 | @router.get("/task/{task_id}") 109 | async def api_admin_task_get(task: CURR_TASK, user: CURR_ADMIN) -> schema.Task.admin_model: 110 | return task 111 | 112 | 113 | @router.post("/task/{task_id}") 114 | async def api_admin_task_edit(new_task: schema.Task, task: CURR_TASK, user: CURR_ADMIN) -> schema.Task.admin_model: 115 | task = await task.update_entry(new_task) # TODO: remove bullshit. 116 | return task 117 | 118 | 119 | # TODO: А можно ли это сделать нормально? 120 | 121 | 122 | # class InternalObjTasksList(schema.EBaseModel): 123 | # tasks: List[uuid.UUID] 124 | 125 | 126 | # @router.post("/tasks/bulk_unhide") 127 | # async def api_admin_tasks_bulk_unhide(tasks: InternalObjTasksList, user: CURR_ADMIN): 128 | # ret = {} 129 | # for task_id in tasks.tasks: 130 | # task = await db.get_task_uuid(task_id) 131 | # if task: 132 | # task.hidden = not task.hidden 133 | # ret[task.task_id] = task.hidden 134 | # return ret 135 | 136 | 137 | # class InternalObjTasksListDecay(InternalObjTasksList): 138 | # tasks: List[uuid.UUID] 139 | # decay: int 140 | 141 | 142 | # not work. 143 | # @router.post("/tasks/bulk_edit_decay") 144 | # async def api_admin_tasks_bulk_edit_decay( 145 | # tasks: InternalObjTasksListDecay, 146 | # user: CURR_ADMIN, 147 | # ): 148 | # ret = {} 149 | # for task_id in tasks.tasks: 150 | # task = await db.get_task_uuid(task_id) 151 | # if task: 152 | # if task.scoring.classtype == schema.scoring.DynamicKKSScoring: 153 | # task.scoring.decay = tasks.decay 154 | # ret[task.task_id] = task.scoring.decay 155 | # return ret 156 | -------------------------------------------------------------------------------- /app/schema/task.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import uuid 3 | from typing import Annotated, ClassVar 4 | from zoneinfo import ZoneInfo 5 | 6 | from pydantic import Field 7 | 8 | from .. import config 9 | from ..config import settings 10 | from ..utils import md 11 | from ..utils.log_helper import get_logger 12 | from .ebasemodel import EBaseModel 13 | from .flags import DynamicKKSFlag, Flag, StaticFlag 14 | from .scoring import DynamicKKSScoring, Scoring, StaticScoring 15 | from .user import User 16 | 17 | logger = get_logger("schema.task") 18 | 19 | TARGET_TZ = ZoneInfo("Europe/Moscow") 20 | 21 | 22 | def template_format_time(date: datetime.datetime) -> str: # from alb1or1x_shit.py 23 | if Task.is_date_after_migration(date): 24 | localized_date = date.astimezone(tz=TARGET_TZ) 25 | # return date.strftime("%H:%M:%S.%f %d.%m.%Y") # str(round(date.timestamp(), 2)) 26 | t = datetime.datetime.now(tz=datetime.UTC) - date 27 | return f"{Task.humanize_time(t)} ago / {localized_date.strftime('%H:%M:%S')}" 28 | return "unknown" 29 | 30 | 31 | ScoringUnion = Annotated[ 32 | StaticScoring | DynamicKKSScoring, 33 | Field(discriminator="classtype"), 34 | ] 35 | FlagUnion = Annotated[ 36 | StaticFlag | DynamicKKSFlag, 37 | Field(discriminator="classtype"), 38 | ] 39 | 40 | 41 | class Task(EBaseModel): 42 | __public_fields__: ClassVar = { 43 | "task_id", 44 | "task_name", 45 | "category", 46 | "scoring", 47 | "description_html", 48 | "author", 49 | "pwned_by", 50 | } 51 | __admin_only_fields__: ClassVar = { 52 | "description", 53 | "flag", 54 | "hidden", 55 | } 56 | 57 | task_id: uuid.UUID = Field(default_factory=uuid.uuid4) 58 | 59 | task_name: str 60 | category: str 61 | 62 | scoring: ScoringUnion 63 | 64 | description: str 65 | description_html: str 66 | 67 | flag: FlagUnion 68 | 69 | pwned_by: dict[uuid.UUID, datetime.datetime] = {} 70 | 71 | hidden: bool = True 72 | 73 | author: str 74 | 75 | # @computed_field 76 | @property 77 | def color_category(self) -> str: 78 | if self.category.lower() == "crypto": 79 | return "crypto" 80 | elif self.category.lower() == "web": 81 | return "web" 82 | elif self.category.lower() in ["binary", "reverse", "pwn", "rev"]: 83 | return "binary" 84 | elif self.category.lower() == "forensic": 85 | return "forensic" 86 | return "other" 87 | 88 | def visible_for_user(self, user: User | None = None) -> bool: 89 | # if admin: always display task. 90 | if user and user.is_admin: 91 | return True 92 | 93 | # if event not started yet 94 | if datetime.datetime.now(tz=datetime.UTC) <= settings.EVENT_START_TIME: 95 | return False 96 | 97 | # if task is hidden and no user/not admin: 98 | # always hide 99 | if self.hidden: 100 | return False 101 | 102 | return True 103 | 104 | @staticmethod 105 | def regenerate_md(content: str) -> str: 106 | return md.markdownCSS(content, config.MD_CLASSES_TASKS, config.MD_ATTRS_TASKS) 107 | 108 | @staticmethod 109 | def is_date_after_migration(dt: datetime.datetime) -> bool: 110 | migration_time = datetime.datetime.fromtimestamp(1605065347, tz=datetime.UTC) 111 | if dt > migration_time: 112 | return True 113 | return False 114 | 115 | # TODO: @Rubikoid, move this code somewhere else? 116 | @staticmethod 117 | def humanize_time(delta: datetime.timedelta) -> str: 118 | dt = datetime.datetime.min + delta # timedelta to datetime conversion :shrug: 119 | if dt.year > 1: 120 | return f"{dt.year - 1} year{'' if dt.year == 2 else 's'}" 121 | if dt.month > 1: 122 | return f"{dt.month - 1} month{'' if dt.month == 2 else 's'}" 123 | if dt.day > 1: 124 | return f"{dt.day - 1} day{'' if dt.day == 2 else 's'}" 125 | elif dt.hour > 0: 126 | return f"{dt.hour} hour{'' if dt.hour == 1 else 's'}" 127 | elif dt.minute > 0: 128 | return f"{dt.minute} minute{'' if dt.minute == 1 else 's'}" 129 | elif dt.second > 0: 130 | return f"{dt.second} second{'' if dt.second == 1 else 's'}" 131 | return "" 132 | 133 | def last_pwned_str(self) -> tuple[uuid.UUID, str]: 134 | last_pwn = max(self.pwned_by.items(), key=lambda x: x[1]) 135 | 136 | last_time = datetime.datetime.now(tz=datetime.UTC) - last_pwn[1] 137 | result_time = Task.humanize_time(last_time) if Task.is_date_after_migration(last_pwn[1]) else "unknown" 138 | 139 | return last_pwn[0], result_time 140 | 141 | def first_pwned_str(self) -> tuple[uuid.UUID, str]: 142 | first_pwn = min(self.pwned_by.items(), key=lambda x: x[1]) 143 | result_time = template_format_time(first_pwn[1]) 144 | 145 | return first_pwn[0], result_time 146 | 147 | def short_desc(self) -> str: 148 | return f"task_id={self.task_id} task_name={self.task_name} hidden={self.hidden} points={self.scoring.points}" 149 | 150 | 151 | class TaskForm(EBaseModel): 152 | task_name: str 153 | category: str 154 | scoring: ScoringUnion 155 | description: str 156 | flag: FlagUnion 157 | author: str = "" 158 | -------------------------------------------------------------------------------- /app/view/templates/admin/base.jhtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% set head_data = namespace() %} 7 | {% set head_data.page_name = "YATB ADMIN" %} 8 | 9 | {% set head_data.pages = { 10 | ("TASKS ADMIN",): url_for('admin_tasks'), 11 | ("USERS ADMIN",): url_for('admin_users'), 12 | ("Back to board",): url_for('index'), 13 | } %} 14 | 15 | {% block head %} 16 | {% block title %} {{ head_data.page_name }} {% endblock %} 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | {% block css %} 27 | 28 | 29 | {% endblock %} 30 | {% endblock %} 31 | 32 | 33 | 34 | {% set header_data = namespace() %} 35 | {% set header_data.yatb_logo_target = "/admin" %} 36 | {% set header_data.yatb_logo_text = "YATB ADMIN" %} 37 | 38 | {% block header %} 39 | 65 | {% endblock %} 66 | 67 | {# {% block toasts %} 68 |
69 |
70 |
71 | {% endblock %} #} 72 | 73 | {% block content %}{% endblock %} 74 | 75 |
76 | {% block footer %} 77 |
78 | [{{ version_string() }}] 79 |
80 | {% endblock %} 81 |
82 | 83 | {% block scripts %} 84 | 85 | 86 | 87 | 88 | 89 | 90 | {# #} 91 | 92 | 93 | 94 | 95 | 98 | 99 | 100 | {% endblock %} 101 | 102 | 103 | -------------------------------------------------------------------------------- /app/schema/auth/simple.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | import os 4 | from collections.abc import Callable 5 | from typing import ClassVar, Literal, Self 6 | 7 | from fastapi import HTTPException, Request, Response, status 8 | from pydantic_settings import SettingsConfigDict 9 | 10 | from ...config import settings 11 | from ...utils.log_helper import get_logger 12 | from ..ebasemodel import EBaseModel 13 | from .auth_base import AuthBase 14 | 15 | logger = get_logger("schema.auth") 16 | 17 | 18 | def hash_password(password: str, salt: bytes | None = None) -> tuple[bytes, bytes]: 19 | salt = salt or os.urandom(32) 20 | pw_hash = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 100000) 21 | return salt, pw_hash 22 | 23 | 24 | def check_password(salt: bytes, pw_hash: bytes, password: str) -> bool: 25 | return hmac.compare_digest(pw_hash, hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 100000)) 26 | 27 | 28 | class SimpleAuth(AuthBase): 29 | class AuthModel(AuthBase.AuthModel): 30 | __admin_only_fields__: ClassVar = { 31 | "username", 32 | } 33 | __private_fields__: ClassVar = { 34 | "password_hash", 35 | } 36 | classtype: Literal["SimpleAuth"] = "SimpleAuth" 37 | 38 | username: str 39 | password_hash: tuple[bytes, bytes] 40 | 41 | def is_admin(self) -> bool: 42 | if settings.DEBUG and self.username == SimpleAuth.auth_settings.DEBUG_USERNAME: 43 | return True 44 | return False 45 | 46 | @classmethod 47 | def get_uniq_field_name(cls: type[Self]) -> str: 48 | return "username" 49 | 50 | def generate_username(self) -> str: 51 | return self.username 52 | 53 | class Form(AuthBase.Form): 54 | class _Internal(EBaseModel): 55 | username: str 56 | password: str 57 | 58 | internal: _Internal 59 | 60 | def check_password(self, model: "SimpleAuth.AuthModel") -> bool: 61 | return check_password(model.password_hash[0], model.password_hash[1], self.internal.password) 62 | 63 | def check_valid(self) -> bool: 64 | if settings.DEBUG: 65 | return True 66 | if ( 67 | len(self.internal.username) < SimpleAuth.auth_settings.MIN_USERNAME_LEN 68 | or len(self.internal.username) > SimpleAuth.auth_settings.MAX_USERNAME_LEN 69 | ): 70 | return False 71 | if len(self.internal.password) < SimpleAuth.auth_settings.MIN_PASSWORD_LEN: 72 | return False 73 | return True 74 | 75 | async def populate(self, req: Request, resp: Response) -> "SimpleAuth.AuthModel": 76 | if not self.check_valid(): 77 | raise HTTPException( 78 | status_code=status.HTTP_400_BAD_REQUEST, 79 | detail="Invalid data", 80 | ) 81 | password_hash = hash_password(self.internal.password) 82 | return SimpleAuth.AuthModel(username=self.internal.username, password_hash=password_hash) 83 | 84 | class AuthSettings(AuthBase.AuthSettings): 85 | DEBUG_USERNAME: str = "Rubikoid" 86 | 87 | MIN_PASSWORD_LEN: int = 8 88 | MIN_USERNAME_LEN: int = 2 89 | MAX_USERNAME_LEN: int = 32 90 | 91 | model_config = SettingsConfigDict(AuthBase.AuthSettings.model_config, env_prefix="AUTH_SIMPLE_") 92 | 93 | auth_settings: ClassVar[AuthSettings] = AuthSettings() 94 | router_params: ClassVar = {} 95 | 96 | @classmethod 97 | def generate_html(cls: type[Self], url_for: Callable) -> str: 98 | if not settings.DEBUG: 99 | login_resrictions = "minlength='2' maxlength='32'" 100 | passw_resrictions = "minlength='8'" 101 | else: 102 | login_resrictions = "" 103 | passw_resrictions = "" 104 | return f""" 105 | Login:
106 | 111 | 112 | Register:
113 |
114 | 115 | 116 | 117 |
118 | """ 119 | 120 | @classmethod 121 | def generate_script(cls: type[Self], url_for: Callable) -> str: 122 | return """ 123 | $(".login_form").submit(function(event) { 124 | event.preventDefault(); 125 | req(api_list["api_auth_simple_login"], { data: getFormData(this), }) 126 | .then(get_json) 127 | .then(redirect, nok_toast_generator("login")) 128 | }); 129 | 130 | $(".register_form").submit(function(event) { 131 | event.preventDefault(); 132 | req(api_list["api_auth_simple_register"], { data: getFormData(this), }) 133 | .then(get_json) 134 | .then(redirect, nok_toast_generator("register")) 135 | }); 136 | """ 137 | -------------------------------------------------------------------------------- /app/view/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import uuid 3 | from collections.abc import Mapping 4 | from pathlib import Path 5 | 6 | from fastapi import BackgroundTasks, Depends, Request, Response 7 | from fastapi.routing import APIRoute as _APIRoute 8 | from fastapi.routing import APIRouter 9 | from fastapi.templating import Jinja2Templates 10 | from formgen.gen2 import Context as FormContext 11 | from formgen.gen2 import Contexts as FormContexts 12 | from formgen.gen2 import FieldType as FormFieldType 13 | from formgen.gen2 import generate_form 14 | from starlette.routing import Router 15 | from starlette.templating import _TemplateResponse 16 | 17 | from .. import auth, schema 18 | from ..api import api_tasks, api_users 19 | from ..config import settings 20 | from ..utils.log_helper import get_logger 21 | 22 | logger = get_logger("view") 23 | 24 | _base_path = Path(__file__).resolve().parent 25 | templ = Jinja2Templates(directory=_base_path / "templates") 26 | 27 | router = APIRouter( 28 | prefix="", 29 | tags=["view"], 30 | ) 31 | 32 | 33 | def route_generator(req: Request, base_path: str = "/api", *, ignore_admin: bool = True) -> dict[str, str]: 34 | router: Router = req.scope["router"] 35 | ret = {} 36 | for r in router.routes: 37 | if not isinstance(r, _APIRoute): 38 | continue 39 | if not r.path.startswith(base_path): 40 | continue 41 | if ignore_admin and r.path.startswith(f"{base_path}/admin"): 42 | continue 43 | 44 | dummy_params = {i: f"NONE_{i}" for i in set(r.param_convertors.keys())} 45 | ret[r.name] = str(req.url_for(r.name, **dummy_params)) 46 | return ret 47 | 48 | 49 | async def response_generator( # noqa: PLR0913 # impossible to fix 50 | req: Request, 51 | filename: str, 52 | context: dict = {}, # noqa: B006 # iknew. 53 | status_code: int = 200, 54 | headers: Mapping[str, str] | None = None, 55 | media_type: str | None = None, 56 | background: BackgroundTasks | None = None, 57 | *, 58 | ignore_admin: bool = True, 59 | ) -> _TemplateResponse: 60 | context_base = { 61 | "request": req, 62 | "api_list": route_generator(req, ignore_admin=ignore_admin), 63 | } 64 | context_base.update(context) 65 | return await asyncio.get_running_loop().run_in_executor( 66 | None, 67 | lambda: templ.TemplateResponse( 68 | name=filename, 69 | context=context_base, 70 | status_code=status_code, 71 | headers=headers, 72 | media_type=media_type, 73 | background=background, 74 | ), 75 | ) 76 | 77 | 78 | def version_string() -> str: 79 | return f"kks-tb-{settings.VERSION}" 80 | 81 | 82 | templ.env.globals["version_string"] = version_string 83 | templ.env.globals["len"] = len 84 | templ.env.globals["template_format_time"] = schema.task.template_format_time 85 | templ.env.globals["set"] = set 86 | templ.env.globals["isinstance"] = isinstance 87 | 88 | templ.env.globals["DEBUG"] = settings.DEBUG 89 | templ.env.globals["FLAG_BASE"] = settings.FLAG_BASE 90 | templ.env.globals["CTF_NAME"] = settings.CTF_NAME 91 | 92 | templ.env.globals["generate_form"] = generate_form 93 | templ.env.globals["FormFieldType"] = FormFieldType 94 | templ.env.globals["FormContext"] = FormContext 95 | templ.env.globals["FormContexts"] = FormContexts 96 | 97 | from . import admin # noqa 98 | 99 | router.include_router(admin.router) 100 | 101 | 102 | @router.get("/") 103 | @router.get("/index") 104 | async def index(req: Request, resp: Response, user: auth.CURR_USER_SAFE): 105 | return await tasks_get_all(req, resp, user) 106 | 107 | 108 | @router.get("/tasks") 109 | async def tasks_get_all(req: Request, resp: Response, user: auth.CURR_USER_SAFE): 110 | tasks_list = await api_tasks.api_tasks_get(user) 111 | return await response_generator( 112 | req, 113 | "tasks.jhtml", 114 | { 115 | "request": req, 116 | "curr_user": user, 117 | "tasks": tasks_list, 118 | }, 119 | ) 120 | 121 | 122 | @router.get("/scoreboard") 123 | async def scoreboard_get(req: Request, resp: Response, user: auth.CURR_USER_SAFE): 124 | tasks_list = await api_tasks.api_tasks_get(user) 125 | scoreboard = await api_users.api_scoreboard_get_internal_shrinked() 126 | 127 | return await response_generator( 128 | req, 129 | "scoreboard.jhtml", 130 | { 131 | "request": req, 132 | "curr_user": user, 133 | "scoreboard": scoreboard, 134 | "enumerate": enumerate, 135 | "all_tasks": tasks_list, 136 | "str": str, 137 | }, 138 | ) 139 | 140 | 141 | @router.get("/login") 142 | async def login_get(req: Request, resp: Response, user: auth.CURR_USER_SAFE): 143 | return await response_generator( 144 | req, 145 | "login.jhtml", 146 | { 147 | "request": req, 148 | "curr_user": user, 149 | "auth_ways": schema.auth.ENABLED_AUTH_WAYS, 150 | }, 151 | ) 152 | 153 | 154 | @router.get("/tasks/{task_id}") 155 | async def tasks_get_task( 156 | req: Request, 157 | resp: Response, 158 | task_id: uuid.UUID, 159 | user: auth.CURR_USER_SAFE, 160 | ): 161 | task = await api_tasks.api_task_get(task_id, user) 162 | return await response_generator( 163 | req, 164 | "task.jhtml", 165 | { 166 | "request": req, 167 | "curr_user": user, 168 | "selected_task": task, 169 | }, 170 | ) 171 | -------------------------------------------------------------------------------- /app/cli/client.py: -------------------------------------------------------------------------------- 1 | import typing 2 | import uuid 3 | from types import TracebackType 4 | 5 | import httpx 6 | 7 | from .. import app, auth, config, schema 8 | from .base import settings 9 | from .models import AllTasks, AllUsers, RawTask, RawUser, UserPrivate, UserPublic 10 | 11 | 12 | class YATB: 13 | s: httpx.AsyncClient 14 | 15 | def __init__(self) -> None: 16 | self.s = httpx.AsyncClient(base_url=settings.base_url) 17 | 18 | def set_admin_token(self, token: str = config.settings.API_TOKEN) -> None: 19 | self.s.headers["X-Token"] = token 20 | 21 | def make_user_token(self, user: schema.User) -> str: 22 | return f"Bearer {auth.create_user_token(user)}" 23 | 24 | async def register_user(self, user: RawUser) -> UserPrivate: 25 | resp = await self.s.post( 26 | app.url_path_for("api_auth_simple_register"), 27 | json=schema.SimpleAuth.Form._Internal( 28 | username=user.username, 29 | password=user.password, 30 | ).model_dump(mode="json"), 31 | ) 32 | resp.raise_for_status() 33 | 34 | ret = await self.find_user_by_name(user.username) 35 | if not ret: 36 | raise Exception("WTF") 37 | return ret 38 | 39 | async def get_self(self) -> UserPublic: 40 | return UserPublic.model_validate((await self.s.get(app.url_path_for("api_users_me"))).json()) 41 | 42 | async def get_all_tasks(self) -> dict[uuid.UUID, schema.Task]: 43 | resp = AllTasks.model_validate((await self.s.get(app.url_path_for("api_admin_tasks"))).json()) 44 | return resp.root 45 | 46 | async def get_all_users(self) -> dict[uuid.UUID, UserPrivate]: 47 | resp = AllUsers.model_validate((await self.s.get(app.url_path_for("api_admin_users"))).json()) 48 | return resp.root 49 | 50 | async def assign_task_to_user(self, user_id: uuid.UUID, task_id: uuid.UUID) -> UserPrivate: 51 | resp = await self.s.post( 52 | app.url_path_for("api_admin_assign_task_to_user", user_id=user_id), 53 | params={"task_id": str(task_id)}, 54 | ) 55 | return UserPrivate.model_validate(resp.json()) 56 | 57 | async def deassign_task_to_user(self, user_id: uuid.UUID, task_id: uuid.UUID) -> UserPrivate: 58 | resp = await self.s.post( 59 | app.url_path_for("api_admin_deassign_task_to_user", user_id=user_id), 60 | params={"task_id": str(task_id)}, 61 | ) 62 | return UserPrivate.model_validate(resp.json()) 63 | 64 | async def detele_everything_but_tasks(self): 65 | resp = await self.s.delete(app.url_path_for("api_detele_everything_but_tasks")) 66 | resp.raise_for_status() 67 | 68 | async def detele_everything(self): 69 | resp = await self.s.delete(app.url_path_for("api_detele_everything")) 70 | resp.raise_for_status() 71 | 72 | async def find_user_by_name(self, username: str) -> UserPrivate | None: 73 | users = await self.get_all_users() 74 | for user in users.values(): 75 | if user.username == username: 76 | return user 77 | 78 | return None 79 | 80 | async def find_task_by_name(self, task_name: str) -> schema.Task | None: 81 | tasks = await self.get_all_tasks() 82 | for task in tasks.values(): 83 | if task.task_name == task_name: 84 | return task 85 | 86 | return None 87 | 88 | async def create_task(self, task: RawTask) -> schema.Task: 89 | new_task = ( 90 | await self.s.post( 91 | app.url_path_for("api_admin_task_create"), 92 | json=schema.TaskForm( 93 | task_name=task.task_name, 94 | description=task.description, 95 | category=task.category, 96 | flag=schema.flags.StaticFlag(flag=task.flag, flag_base=settings.flag_base), 97 | scoring=schema.scoring.DynamicKKSScoring(), 98 | author=task.author, 99 | ).model_dump(mode="json"), 100 | ) 101 | ).json() 102 | return schema.Task.model_validate(new_task) 103 | 104 | async def admin_recalc_scoreboard(self) -> None: 105 | resp = await self.s.get(app.url_path_for("api_admin_recalc_scoreboard")) 106 | resp.raise_for_status() 107 | 108 | async def admin_recalc_tasks(self) -> None: 109 | resp = await self.s.get(app.url_path_for("api_admin_recalc_tasks")) 110 | resp.raise_for_status() 111 | 112 | async def update_task(self, task: schema.Task) -> schema.Task: 113 | new_task = ( 114 | await self.s.post( 115 | app.url_path_for("api_admin_task_edit", task_id=task.task_id), 116 | json=task.model_dump(mode="json"), 117 | ) 118 | ).json() 119 | return schema.Task.model_validate(new_task) 120 | 121 | async def solve_as_user(self, user: schema.User, flag: str) -> str: 122 | token = self.make_user_token(user) 123 | resp = await self.s.post( 124 | app.url_path_for("api_task_submit_flag"), 125 | json=schema.FlagForm(flag=flag).model_dump(mode="json"), 126 | headers={"X-Auth-Token": token}, 127 | ) 128 | resp.raise_for_status() 129 | return resp.text 130 | 131 | async def __aenter__(self): 132 | self.s = await self.s.__aenter__() 133 | return self 134 | 135 | async def __aexit__( 136 | self, 137 | exc_type: typing.Type[BaseException] | None = None, 138 | exc_value: BaseException | None = None, 139 | traceback: TracebackType | None = None, 140 | ) -> None: 141 | await self.s.__aexit__(exc_type=exc_type, exc_value=exc_value, traceback=traceback) # type: ignore 142 | -------------------------------------------------------------------------------- /app/api/api_auth.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from typing import Literal, cast 3 | 4 | from fastapi import APIRouter, Depends, HTTPException, Request, Response, status 5 | 6 | from .. import auth, db, schema 7 | from ..db.beanie import UserDB 8 | from ..utils import metrics 9 | from . import logger 10 | 11 | router = APIRouter( 12 | prefix="/auth", 13 | tags=["auth"], 14 | ) 15 | 16 | 17 | async def check_for_existing_model( 18 | model: schema.auth.AuthBase.AuthModel, 19 | check_for_class: type[schema.auth.AuthBase.AuthModel], 20 | ): 21 | username = model.generate_username() 22 | user_by_username = await UserDB.find_by_username(username) 23 | if user_by_username and not isinstance(user_by_username.auth_source, check_for_class): 24 | raise HTTPException( 25 | status_code=status.HTTP_403_FORBIDDEN, 26 | detail="User/Team already exists. If you want to migrate between password <-> ctftime auth, contact orgs", 27 | ) 28 | 29 | 30 | def generic_handler_generator(cls: type[schema.auth.AuthBase]) -> Callable: 31 | """ 32 | This is a little crazy "generic generator" for handling universal auth way. 33 | Should work for most of possible authentification ways. 34 | """ 35 | 36 | async def generic_handler( 37 | req: Request, 38 | resp: Response, 39 | form: "schema.auth.AuthBase.Form" = Depends(), 40 | ) -> Literal["ok"]: 41 | # create model from form. 42 | model = await form.populate(req, resp) 43 | 44 | # check for team with same name, but from other reg source. 45 | await check_for_existing_model(model, cls.AuthModel) 46 | 47 | # extract primary (unique) field from model, and check 48 | # is user with that field exists 49 | user = await UserDB.get_user_uniq_field(cls.AuthModel, model.get_uniq_field()) 50 | 51 | if user is None: 52 | # if not: create new user 53 | user = await UserDB.populate(model) 54 | metrics.users.inc() 55 | elif user.admin_checker() and not user.is_admin: 56 | # if users exists: check and promote to admin. conceptual shit. 57 | logger.warning(f"Promoting old {user} to admin") 58 | user.is_admin = True 59 | await user.save() # type: ignore # great library 60 | 61 | metrics.logons_per_user.labels(user_id=user.user_id, username=user.username).inc() 62 | 63 | # create token for user, and put it in cookie 64 | access_token = auth.create_user_token(user) 65 | resp.set_cookie(key="access_token", value=f"Bearer {access_token}", httponly=True) 66 | 67 | resp.status_code = status.HTTP_303_SEE_OTHER 68 | resp.headers["Location"] = str(req.url_for("index")) 69 | 70 | return "ok" 71 | 72 | generic_handler.__annotations__["form"] = cls.Form 73 | return generic_handler 74 | 75 | 76 | async def api_auth_simple_login(req: Request, resp: Response, form: schema.SimpleAuth.Form = Depends()): 77 | # almost the same generic, but for login/password form, due to additional login. 78 | model = await form.populate(req, resp) 79 | user = await UserDB.get_user_uniq_field(schema.SimpleAuth.AuthModel, model.get_uniq_field()) 80 | if not user: 81 | raise HTTPException( 82 | status_code=status.HTTP_401_UNAUTHORIZED, 83 | detail="Incorrect username or password", 84 | ) 85 | 86 | auth_source = cast(schema.SimpleAuth.AuthModel, user.auth_source) 87 | if not form.check_password(auth_source): 88 | raise HTTPException( 89 | status_code=status.HTTP_401_UNAUTHORIZED, 90 | detail="Incorrect username or password", 91 | ) 92 | 93 | metrics.logons_per_user.labels(user_id=user.user_id, username=user.username).inc() 94 | 95 | access_token = auth.create_user_token(user) 96 | resp.set_cookie(key="access_token", value=f"Bearer {access_token}", httponly=True) 97 | 98 | return "ok" 99 | 100 | 101 | async def api_auth_simple_register(req: Request, resp: Response, form: schema.SimpleAuth.Form = Depends()): 102 | # almost the same generic, but for login/password form, due to additional login. 103 | model = await form.populate(req, resp) 104 | 105 | # check for team with same name, but from other reg source. 106 | await check_for_existing_model(model, schema.SimpleAuth.AuthModel) 107 | 108 | user = await UserDB.get_user_uniq_field(schema.SimpleAuth.AuthModel, model.get_uniq_field()) 109 | if user: 110 | raise HTTPException( 111 | status_code=status.HTTP_403_FORBIDDEN, 112 | detail="Team exists", 113 | ) 114 | 115 | user = await UserDB.populate(model) 116 | metrics.users.inc() 117 | 118 | access_token = auth.create_user_token(user) 119 | resp.set_cookie(key="access_token", value=f"Bearer {access_token}", httponly=True) 120 | 121 | return "ok" 122 | 123 | 124 | # Create routes for all enabled auth ways 125 | # also, handle login/password way especially... 126 | for auth_way in schema.auth.ENABLED_AUTH_WAYS: 127 | if auth_way.FAKE: 128 | continue 129 | 130 | if auth_way != schema.auth.SimpleAuth: 131 | router.add_api_route( 132 | endpoint=generic_handler_generator(auth_way), 133 | **auth_way.router_params, # type: ignore 134 | ) 135 | else: 136 | router.add_api_route( 137 | endpoint=api_auth_simple_login, 138 | path="/simple_login", 139 | name="api_auth_simple_login", 140 | methods=["POST"], 141 | **auth_way.router_params, # type: ignore 142 | ) 143 | router.add_api_route( 144 | endpoint=api_auth_simple_register, 145 | path="/simple_register", 146 | name="api_auth_simple_register", 147 | methods=["POST"], 148 | **auth_way.router_params, # type: ignore 149 | ) 150 | -------------------------------------------------------------------------------- /app/db/db_tasks.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import uuid 4 | from asyncio import Lock 5 | from typing import Dict, Union 6 | 7 | from .. import schema 8 | from ..config import settings 9 | from ..utils import metrics 10 | from ..utils.log_helper import get_logger 11 | from . import db_users, update_entry 12 | 13 | # import markdown2 14 | 15 | 16 | logger = get_logger("db.tasks") 17 | db_lock = Lock() 18 | 19 | 20 | async def get_task_uuid(uuid: uuid.UUID) -> schema.Task: 21 | from . import _db 22 | 23 | if uuid in _db._index["tasks"]: 24 | return _db._index["tasks"][uuid] 25 | 26 | 27 | async def get_all_tasks() -> Dict[uuid.UUID, schema.Task]: 28 | from . import _db 29 | 30 | return _db._index["tasks"] 31 | 32 | 33 | async def check_task_uuid(uuid: uuid.UUID) -> bool: 34 | from . import _db 35 | 36 | return uuid in _db._index["tasks"] 37 | 38 | 39 | async def insert_task(new_task: schema.TaskForm, author: schema.User) -> schema.Task: 40 | from . import _db 41 | 42 | # task = schema.Task.parse_obj(new_task) # WTF: SHITCODE 43 | task = schema.Task( 44 | task_name=new_task.task_name, 45 | category=new_task.category, 46 | scoring=new_task.scoring, 47 | description=new_task.description, 48 | description_html=schema.Task.regenerate_md(new_task.description), 49 | flag=new_task.flag, 50 | author=(new_task.author if new_task.author != "" else f"@{author.username}"), 51 | ) 52 | 53 | _db._db["tasks"][task.task_id] = task 54 | _db._index["tasks"][task.task_id] = task 55 | return task 56 | 57 | 58 | async def update_task(task: schema.Task, new_task: schema.Task) -> schema.Task: 59 | from . import _db 60 | 61 | logger.debug(f"Update task {task} to {new_task}") 62 | 63 | update_entry( 64 | task, 65 | new_task.dict( 66 | exclude={ 67 | "task_id", 68 | "description_html", 69 | "scoring", 70 | "flag", 71 | "pwned_by", 72 | } 73 | ), 74 | ) 75 | task.scoring = new_task.scoring # fix for json-ing scoring on edit 76 | task.flag = new_task.flag # fix for json-ing flag on edit 77 | 78 | logger.debug(f"Resulting task={task}") 79 | task.description_html = schema.Task.regenerate_md(task.description) 80 | return task 81 | 82 | 83 | async def remove_task(task: schema.Task): 84 | from . import _db 85 | 86 | # TODO: recalc score and something else. 87 | await unsolve_task(task) 88 | del _db._db["tasks"][task.task_id] 89 | del _db._index["tasks"][task.task_id] 90 | 91 | 92 | async def find_task_by_flag(flag: str, user: schema.User) -> Union[schema.Task, None]: 93 | from . import _db 94 | 95 | for task_id, task in _db._db["tasks"].items(): 96 | task: schema.Task # strange solution, but no other ideas 97 | if task.flag.flag_checker(flag, user): 98 | return task 99 | 100 | return None 101 | 102 | 103 | async def solve_task(task: schema.Task, solver: schema.User): 104 | if solver.is_admin and not settings.DEBUG: # if you admin, you can't solve task. 105 | return task.task_id 106 | 107 | if datetime.datetime.now(tz=datetime.UTC) > settings.EVENT_END_TIME: 108 | return task.task_id 109 | 110 | # WTF: UNTEDTED: i belive this will work as a monkey patch for rAcE c0nDiTioN 111 | global db_lock 112 | async with db_lock: 113 | # add references 114 | solv_time = datetime.datetime.now(tz=datetime.UTC) 115 | solver.solved_tasks[task.task_id] = solv_time 116 | task.pwned_by[solver.user_id] = solv_time 117 | 118 | # get previous score 119 | prev_score = task.scoring.points 120 | solver.score += prev_score 121 | 122 | # if do_recalc, recalc all the scoreboard... only users, who solved task 123 | do_recalc = task.scoring.solve_task() 124 | if do_recalc: 125 | new_score = task.scoring.points 126 | diff = prev_score - new_score 127 | logger.info(f"Solve task: {task.short_desc()}, oldscore={prev_score}, newscore={new_score}, diff={diff}") 128 | for solver_id in task.pwned_by: 129 | solver_recalc = await db_users.get_user_uuid(solver_id) 130 | solver_recalc.score -= diff 131 | metrics.score_per_user.labels(user_id=solver_recalc.user_id, username=solver_recalc.username).set( 132 | solver_recalc.score 133 | ) 134 | 135 | return task.task_id 136 | 137 | 138 | async def unsolve_task(task: schema.Task) -> schema.Task: 139 | # add references 140 | global db_lock 141 | async with db_lock: 142 | task.pwned_by.clear() 143 | # TODO: оптимизировать эту ебатеку 144 | for _, user in (await db_users.get_all_users()).items(): 145 | if task.task_id in user.solved_tasks: 146 | user.solved_tasks.pop(task.task_id) 147 | # task.scoring 148 | 149 | await recalc_scoreboard() 150 | return task 151 | 152 | 153 | async def recalc_user_score(user: schema.User, _task_cache: Dict[uuid.UUID, schema.Task] = None): 154 | if _task_cache is None: 155 | _task_cache = {} 156 | old_score = user.score 157 | user.score = 0 158 | for task_id in user.solved_tasks: 159 | if task_id not in _task_cache: 160 | _task_cache[task_id] = await get_task_uuid(task_id) 161 | if _task_cache[task_id] is None: 162 | continue 163 | user.score += _task_cache[task_id].scoring.points 164 | _task_cache[task_id].scoring.set_solves(len(_task_cache[task_id].pwned_by)) 165 | if old_score != user.score: 166 | logger.warning(f"Recalc: smth wrong with {user.short_desc()}, {old_score} != {user.score}!") 167 | 168 | 169 | async def recalc_scoreboard(): 170 | _task_cache: Dict[uuid.UUID, schema.Task] = {} 171 | global db_lock 172 | async with db_lock: 173 | for _, user in (await db_users.get_all_users()).items(): 174 | await recalc_user_score(user, _task_cache) 175 | -------------------------------------------------------------------------------- /app/view/templates/base.jhtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% set head_data = namespace() %} 7 | {% set head_data.page_name = "YATB" %} 8 | 9 | {% set head_data.pages = { 10 | ("Challenges", "All tasks"): url_for('tasks_get_all'), 11 | ("Scoreboard",): url_for('scoreboard_get'), 12 | } %} 13 | 14 | {% block head %} 15 | {% block title %} {{ head_data.page_name }} {% endblock %} 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | {% block css %} 25 | 26 | 27 | {% endblock %} 28 | {% endblock %} 29 | 30 | 31 | {% set header_data = namespace() %} 32 | {% set header_data.yatb_logo_target = "/" %} 33 | {% set header_data.yatb_logo_text = CTF_NAME %} 34 | 35 | {% block header %} 36 | 86 | {% endblock %} 87 | 88 | {% block content %}{% endblock %} 89 | 90 |
91 | {% block footer %} 92 |
93 | [{{ version_string() }}] 94 |
95 | {% endblock %} 96 |
97 | 98 | {% block scripts %} 99 | 100 | 101 | 102 | 103 | {# #} 104 | 105 | 106 | 107 | 108 | 114 | 115 | 116 | {% endblock %} 117 | 118 | 119 | -------------------------------------------------------------------------------- /app/schema/ebasemodel.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import types 3 | import typing 4 | from typing import TYPE_CHECKING, Any, ClassVar, Self, TypeAlias, Union, get_args, get_origin 5 | 6 | from pydantic import BaseModel, computed_field, create_model 7 | from pydantic.fields import FieldInfo 8 | 9 | from ..utils.log_helper import get_logger 10 | 11 | if TYPE_CHECKING: 12 | from pydantic.main import IncEx 13 | 14 | logger = get_logger("schema") 15 | 16 | FilterFieldsType: TypeAlias = set[str] # | dict[str, object] 17 | 18 | 19 | def origin_is_union(tp: type[Any] | None) -> bool: 20 | return tp is typing.Union or tp is types.UnionType 21 | 22 | 23 | class EBaseModel(BaseModel): 24 | __public_fields__: ClassVar[FilterFieldsType] = set() 25 | __admin_only_fields__: ClassVar[FilterFieldsType] = set() 26 | __private_fields__: ClassVar[FilterFieldsType] = set() 27 | 28 | @classmethod 29 | def build_model( # noqa: PLR0912, C901 # WTF: refactor & simplify 30 | cls: type[Self], 31 | include: FilterFieldsType, 32 | exclude: FilterFieldsType, 33 | name: str = "sub", 34 | *, 35 | public: bool = True, 36 | ) -> type[Self]: 37 | target_fields: dict[str, tuple[type, FieldInfo]] = {} 38 | 39 | for field_name, field_value in cls.model_fields.items(): 40 | if field_name in exclude: 41 | continue 42 | 43 | if field_name not in include: 44 | logger.error(f"Unlisted field at {cls.__qualname__}: {field_name}, {field_value = }") 45 | continue 46 | 47 | if field_value.annotation is None: 48 | logger.error(f"WTF Field {cls.__qualname__}: {field_name}, {field_value = }") 49 | continue 50 | 51 | if origin_is_union(get_origin(field_value.annotation)): 52 | # logger.debug(f"Found union field at {cls.__qualname__}: {field_name}") 53 | new_union_base: list[Any] = [] 54 | for union_member in get_args(field_value.annotation): 55 | if issubclass(union_member, EBaseModel): 56 | new_member = ( # WTF: make it more robust 57 | union_member._public_model() if public else union_member._admin_model() 58 | ) 59 | new_union_base.append(new_member) 60 | else: 61 | new_union_base.append(union_member) 62 | 63 | new_union = Union[tuple(new_union_base)] # type: ignore # noqa: UP007, PGH003 # так надо. 64 | 65 | target_fields[field_name] = ( 66 | new_union, 67 | field_value, 68 | ) 69 | elif isinstance(field_value.annotation, type) and issubclass(field_value.annotation, EBaseModel): 70 | # logger.debug(f"Found EBaseModel field at {cls.__qualname__}: {field_name}") 71 | new_field_cls = ( 72 | field_value.annotation._public_model() if public else field_value.annotation._admin_model() 73 | ) 74 | target_fields[field_name] = ( 75 | new_field_cls, 76 | field_value, 77 | ) 78 | else: 79 | target_fields[field_name] = ( 80 | field_value.annotation, 81 | field_value, 82 | ) 83 | 84 | for attr_name, attr_value in cls.__dict__.items(): 85 | if not isinstance(attr_value, property): 86 | continue 87 | if attr_name in exclude: 88 | continue 89 | if attr_name not in include: 90 | logger.error(f"Unlisted property at {cls.__qualname__}: {attr_name}") 91 | continue 92 | 93 | logger.debug(f"Found property in {cls}: {attr_name}") 94 | 95 | annotation = attr_value.fget.__annotations__["return"] 96 | target_fields[attr_name] = ( 97 | annotation, 98 | FieldInfo.from_annotation(annotation), 99 | ) 100 | 101 | ret = create_model( 102 | f"{cls.__qualname__}_{name}", 103 | __config__=cls.model_config, 104 | __module__=f"{cls.__module__}.dynamic", 105 | # __validators__=cls.__pydantic_decorators__, # type: ignore # WTF: why commented 106 | **target_fields, # type: ignore 107 | ) 108 | ret.__pydantic_decorators__ = cls.__pydantic_decorators__ 109 | 110 | return ret 111 | 112 | @classmethod 113 | @functools.lru_cache(typed=True) 114 | def _public_model(cls: type[Self]) -> type[Self]: 115 | return cls.build_model( 116 | cls.__public_fields__, 117 | cls.join_fields(cls.__private_fields__, cls.__admin_only_fields__), 118 | name="public", 119 | public=True, 120 | ) 121 | 122 | @classmethod 123 | @functools.lru_cache(typed=True) 124 | def _admin_model(cls: type[Self]) -> type[Self]: 125 | return cls.build_model( 126 | cls.join_fields(cls.__public_fields__, cls.__admin_only_fields__), 127 | cls.__private_fields__, 128 | name="private", 129 | public=False, 130 | ) 131 | 132 | @staticmethod 133 | def join_fields(f1: FilterFieldsType, f2: FilterFieldsType) -> FilterFieldsType: 134 | ret = set() 135 | # if isinstance(f1, dict) or isinstance(f2, dict): 136 | # ret = {} 137 | # # ret |= ({i: ... for i in f1} else f1) 138 | # if isinstance(f1, set): 139 | # ret |= {i: ... for i in f1} 140 | # else: 141 | # ret |= f1 142 | 143 | # if isinstance(f2, set): 144 | # ret |= {i: ... for i in f2} 145 | # else: 146 | # ret |= f2 147 | # else: 148 | ret = f1 | f2 149 | 150 | return ret 151 | 152 | @classmethod 153 | @property 154 | def public_model(cls: type[Self]) -> type[Self]: 155 | return cls._public_model() 156 | 157 | @classmethod 158 | @property 159 | def admin_model(cls: type[Self]) -> type[Self]: 160 | return cls._admin_model() 161 | -------------------------------------------------------------------------------- /app/cli/cmd/load.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import shutil 3 | import subprocess 4 | from pathlib import Path 5 | from uuid import UUID 6 | 7 | from pydantic_yaml import parse_yaml_raw_as 8 | 9 | from ...schema.task import Task 10 | from ..base import c, settings, tapp 11 | from ..client import YATB 12 | from ..models import FileTask 13 | 14 | 15 | @tapp.command() 16 | def prepare_tasks( 17 | main_tasks_dir: Path, 18 | static_files_dir: Path, 19 | deploy_files_dir: Path, 20 | *, 21 | drop: bool = False, 22 | live: bool = True, 23 | ): 24 | main_tasks_dir = main_tasks_dir.expanduser().resolve() 25 | static_files_dir = static_files_dir.expanduser().resolve() 26 | deploy_files_dir = deploy_files_dir.expanduser().resolve() 27 | 28 | async def _a(): 29 | caddy_data = "" 30 | used_prefix: set[str] = set() 31 | 32 | async with YATB() as y: 33 | y.set_admin_token() 34 | 35 | if drop: 36 | if static_files_dir.exists(): 37 | c.log("Cleaning static files...") 38 | shutil.rmtree(static_files_dir) 39 | 40 | if deploy_files_dir.exists(): 41 | c.log("Cleaning deploy files...") 42 | shutil.rmtree(deploy_files_dir) 43 | 44 | await y.detele_everything() 45 | 46 | if live and deploy_files_dir.exists(): 47 | c.log("Cleaning deploy files...") 48 | shutil.rmtree(deploy_files_dir) 49 | 50 | static_files_dir.mkdir(parents=True, exist_ok=True) 51 | deploy_files_dir.mkdir(parents=True, exist_ok=True) 52 | 53 | old_tasks: dict[UUID, Task] = {} 54 | 55 | def search_task(target: FileTask) -> Task | None: 56 | for task in old_tasks.values(): 57 | if target.full_name == task.task_name: 58 | return task 59 | return None 60 | 61 | if live: 62 | old_tasks = await y.get_all_tasks() 63 | c.print(f"Running in live mode, found {len(old_tasks)} tasks") 64 | 65 | for category_src in main_tasks_dir.iterdir(): 66 | if not category_src.is_dir(): 67 | continue 68 | 69 | for task_src in category_src.iterdir(): 70 | if not task_src.is_dir(): 71 | continue 72 | 73 | if not (task_src / "task.yaml").exists(): 74 | continue 75 | 76 | task_info = parse_yaml_raw_as(FileTask, (task_src / "task.yaml").read_text()) 77 | 78 | board_task = search_task(task_info) 79 | if live and not board_task: 80 | c.print(f"WTF: {task_info = } not found") 81 | input("wtf?") 82 | 83 | created_task = board_task or await y.create_task(task_info.get_raw()) 84 | c.print(f"Created task: {created_task}") 85 | 86 | public_dir = task_src / "public" 87 | if public_dir.exists() and not live: 88 | task_files_dir = static_files_dir / str(created_task.task_id) 89 | task_files_dir.mkdir(parents=True, exist_ok=True) 90 | 91 | files = list(public_dir.iterdir()) 92 | files_hash = subprocess.check_output( # noqa: ASYNC101 93 | "sha256sum -b public/*", # noqa: S607 94 | shell=True, # noqa: S602 95 | cwd=task_src, 96 | stderr=subprocess.STDOUT, 97 | ) 98 | 99 | created_task.description += "\n\n---\n\n" 100 | created_task.description += '
' 101 | 102 | for file in files: 103 | created_task.description += ( 104 | f"{file.name}\n" 107 | ) 108 | shutil.copy2(file, task_files_dir) 109 | c.print(f"\t\t[+] '{created_task.task_name}': uploaded file {file}") 110 | 111 | (task_files_dir / ".sha256").write_bytes(files_hash) 112 | created_task.description += ( 113 | f".sha256\n" 116 | ) 117 | 118 | created_task.description = created_task.description.strip() + "
" 119 | 120 | created_task = await y.update_task(task=created_task) 121 | 122 | c.print(f"Updated task: {created_task}") 123 | 124 | if ( 125 | task_info.server_port 126 | and task_info.is_http 127 | and (prefix := task_info.domain_prefix) 128 | and prefix not in used_prefix 129 | ): 130 | caddy_data += ( 131 | f"@task-{prefix} host {prefix}.{settings.tasks_domain}\n" 132 | f"handle @task-{prefix} {{\n" 133 | f" reverse_proxy 127.0.0.1:{task_info.server_port}\n" 134 | "}\n\n" 135 | ) 136 | used_prefix.add(prefix) 137 | 138 | deploy_dir = task_src / "deploy" 139 | if deploy_dir.exists(): 140 | target_deploy_dir = deploy_files_dir / task_src.name 141 | shutil.copytree(deploy_dir, target_deploy_dir) 142 | 143 | c.print("Caddy data:") 144 | c.print(caddy_data) 145 | 146 | asyncio.run(_a()) 147 | -------------------------------------------------------------------------------- /app/view/templates/admin/macro.jhtml: -------------------------------------------------------------------------------- 1 | {%- macro get_input( 2 | prop_name, 3 | prop, 4 | attribs, 5 | schema, 6 | value=None, 7 | type_override=None) 8 | %} 9 | {% set type = prop['type'] if not type_override else type_override %} 10 | {% set inner_value = (prop['const'] if 'const' in prop else (value if value else (prop['default'] if 'default' in prop else ''))) %} 11 | {% set inner_value_2 = prop.get('const', value or prop.get('default', '')) %} 12 | 13 | {# #} 14 | {% if type == "string" %} 15 | 16 | {% elif type == "integer" %} 17 | 18 | {% elif type == "boolean" %} 19 | 20 | {% elif type == "textarea" %} 21 | 22 | {% elif type == "array" %} 23 | {# #} 24 | ARRAY_TEXTAREA_DISABLED_DUE_TO_THIS_BROKES_EVERYTHING!!!! 25 | {% elif type == "html" %} 26 | 27 | 30 |
31 |
32 | {{ inner_value | safe }} 33 |
34 |
35 | {% elif type == "class" and 'oneOf' in prop %} {# oneOf check due to narrow list of usages #} 36 | 46 |
47 | {% for ref in prop['oneOf'] %} 48 | {% set ref = ref['$ref'].split('/')[-1] %} 49 | {% if ref in schema['$defs'] %} 50 |
51 | {% set defin = schema['$defs'][ref] %} 52 | {% if not isinstance(attribs, {}.__class__) %} 53 | WTF WITH ATTRIBS : {{ attribs }} 54 | {% set attribs = {} %} 55 | {% endif %} 56 | {# {% if not isinstance(inner_value, {}.__class__) %} 57 | WTF WITH inner_value : {{ inner_value }} 58 | {% set inner_value = {} %} 59 | {% endif %} #} 60 | {% if inner_value == '' %} 61 | {% set inner_value = {} %} 62 | {% endif %} 63 | {% set args = { 64 | "schema": schema, 65 | "selected_props": defin["properties"], 66 | "values": inner_value, 67 | "attribs": attribs, 68 | "prop_name_root": prop_name, 69 | } %} 70 | {{ generate_form_inner(**args) }} 71 |
72 | {% endif %} 73 | {% endfor %} 74 |
75 | {% elif type == "classtype" %} 76 | 77 | {% else %} 78 | _NOT_KNOWN_TYPE_{{ type }}_ 79 | {% endif %} 80 | {%- endmacro %} 81 | 82 | {% macro generate_form_inner( 83 | schema, 84 | selected_props, 85 | values = {}, 86 | overrides = {}, 87 | attribs = {}, 88 | prop_name_root = None) 89 | %} 90 | {% for prop_name, prop in selected_props.items() %} 91 |

92 | {% set args = { 93 | "prop_name": (prop_name_root + "." if prop_name_root else "") + prop_name, 94 | "prop": prop, 95 | "attribs": attribs.get(prop_name, []), 96 | 'schema': schema, 97 | "value": values.get(prop_name, None), 98 | 'type_override': overrides.get(prop_name, None) 99 | } %} 100 | {% if prop_name in schema["required"] and isinstance(args["attribs"], [].__class__) %} 101 | {% set _ = args.update(attribs=args["attribs"] + ["required"]) %} 102 | {% endif %} 103 |

104 | 105 |
{{ get_input(**args) }}
106 |
107 |

108 | {% endfor %} 109 | {% endmacro %} 110 | 111 | {% macro generate_form( 112 | schema, 113 | form_id = "", 114 | form_class = "", 115 | values = {}, 116 | overrides = {}, 117 | attribs = {}) 118 | %} 119 |
120 | {% set args = { 121 | "schema": schema, 122 | "selected_props": schema["properties"], 123 | "values": values, 124 | "overrides": overrides, 125 | "attribs": attribs, 126 | } %} 127 | {{ generate_form_inner(**args) }} 128 | 129 |
130 | {% endmacro %} 131 | -------------------------------------------------------------------------------- /app/view/templates/login.jhtml: -------------------------------------------------------------------------------- 1 | {% extends "base.jhtml" %} 2 | {% import "macro.jhtml" as macro with context %} 3 | 4 | {% block css %} 5 | 20 | 21 | 22 | {% endblock %} 23 | 24 | {% block header %} 25 | {# 26 | {% set head_data.page_name = "Login" %} 27 | {{ super() }} 28 | #} 29 | {% endblock %} 30 | 31 | {% block content %} 32 |
33 | 98 |
99 | {% endblock %} 100 | 101 | {% block footer %} 102 | 103 | {% endblock %} 104 | 105 | {% block scripts %} 106 | {{ super() }} 107 | {% for auth_way in auth_ways %} 108 | {% if not auth_way.FAKE %} 109 | {% set aw_type = auth_way.__name__ %} 110 | 113 | {% endif %} 114 | {% endfor %} 115 | 116 | {% if DEBUG %} 117 | 153 | {% endif %} 154 | {% endblock %} 155 | --------------------------------------------------------------------------------