")
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 | username |
16 | score |
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 | | {{ i + 1 }} |
27 |
28 | {{ user.username }}
29 | |
30 | {{ user.score }} |
31 |
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 | |
54 |
55 | {% endfor %}
56 |
57 |
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 | | Username |
49 | Provider |
50 | Score |
51 | Admin |
52 | Solved tasks |
53 | Actions |
54 |
55 |
56 |
57 | {% for user in users_list %}
58 |
59 | |
60 |
61 | {{ user.username }}
62 |
63 | |
64 | {{ user.auth_source.classtype }} |
65 | {{ user.score }} |
66 | {{ 'Admin' if user.is_admin else '' }} |
67 |
68 | {% for solved_uuid in user.solved_tasks %}
69 | {{ solved_uuid }};
70 | {% endfor %}
71 | |
72 |
73 | Edit
74 | {# #}
75 | {# #}
76 | |
77 |
78 | {% endfor %}
79 |
80 |
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 |
71 | {% endblock %} #}
72 |
73 | {% block content %}{% endblock %}
74 |
75 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------