diff --git a/.gitignore b/.gitignore index f07d444..3ad247f 100644 --- a/.gitignore +++ b/.gitignore @@ -107,6 +107,7 @@ celerybeat.pid # Environments .env .venv +.linvenv env/ venv/ ENV/ @@ -147,5 +148,10 @@ cython_debug/ logs/ +yatb_prod + # env yatb.env +k3s.yaml +dynamic_tasks_app/wtf +yatb_state.json diff --git a/app/__init__.py b/app/__init__.py index 49bd99a..f2f74c4 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,17 +1,32 @@ import logging +from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from . import utils +from . import api, main, utils, view +from .api.api_dynamic_tasks import __client from .config import settings +from .db import beanie + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await beanie.db.init() + async with __client: + try: + yield + finally: + await beanie.db.close() + app = FastAPI( docs_url=settings.FASTAPI_DOCS_URL, redoc_url=settings.FASTAPI_REDOC_URL, openapi_url=settings.FASTAPI_OPENAPI_URL, + lifespan=lifespan, ) _base_path = Path(__file__).resolve().parent @@ -25,12 +40,10 @@ # for i in loggers: # print(f"LOGGER: {i}") -from . import api # noqa -from . import main # noqa -from . import view # noqa - +main.setup_utils(app) app.include_router(api.router) app.include_router(view.router) +app.include_router(view.admin.api_rotuer) # prometheus from prometheus_fastapi_instrumentator import Instrumentator # noqa @@ -43,15 +56,3 @@ env_var_name="ENABLE_METRICS", ) instrumentator.instrument(app).expose(app, endpoint=expose_url) -# utils.metrics.bad_solves_per_user - -""" -@app.on_event("startup") -def startup_event(): - metrics.load_all_metrics() - - -@app.on_event("shutdown") -def shutdown_event(): - metrics.save_all_metrics() -""" diff --git a/app/api/__init__.py b/app/api/__init__.py index e14520d..281d4b0 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -13,8 +13,10 @@ from . import api_auth # noqa from . import api_tasks # noqa from . import api_users # noqa +from . import api_dynamic_tasks # noqa api_users.router.include_router(api_auth.router) router.include_router(api_users.router) router.include_router(api_tasks.router) +router.include_router(api_dynamic_tasks.router) router.include_router(admin.router) diff --git a/app/api/admin/__init__.py b/app/api/admin/__init__.py index 0cec0b3..0b1cd80 100644 --- a/app/api/admin/__init__.py +++ b/app/api/admin/__init__.py @@ -18,14 +18,14 @@ async def admin_checker( user: auth.CURR_USER_SAFE, token_header: str | None = Header(None, alias="X-Token"), token_query: str | None = Query(None, alias="token"), -) -> schema.User: +) -> UserDB: if user and user.is_admin: return user if token_header and token_header == settings.API_TOKEN: - return _fake_admin_user + return _fake_admin_user # type: ignore if token_query and token_query == settings.API_TOKEN: - return _fake_admin_user + return _fake_admin_user # type: ignore raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -33,7 +33,7 @@ async def admin_checker( ) -CURR_ADMIN = Annotated[schema.User, Depends(admin_checker)] +CURR_ADMIN = Annotated[UserDB, Depends(admin_checker)] logger = get_logger("api.admin") router = APIRouter( diff --git a/app/api/admin/admin_tasks.py b/app/api/admin/admin_tasks.py index ef467ff..2d154ce 100644 --- a/app/api/admin/admin_tasks.py +++ b/app/api/admin/admin_tasks.py @@ -1,5 +1,6 @@ import uuid -from typing import Annotated, Mapping +from collections.abc import Mapping +from typing import Annotated from beanie import BulkWriter from beanie.operators import Set diff --git a/app/api/admin/admin_users.py b/app/api/admin/admin_users.py index a58f230..75ece1f 100644 --- a/app/api/admin/admin_users.py +++ b/app/api/admin/admin_users.py @@ -16,7 +16,7 @@ async def api_admin_users_internal() -> Mapping[uuid.UUID, schema.User]: return all_users -async def api_admin_user_get_internal(user_id: uuid.UUID) -> UserDB: +async def get_user(user_id: uuid.UUID) -> UserDB: user = await UserDB.find_by_user_uuid(user_id) if not user: raise HTTPException( @@ -27,29 +27,33 @@ async def api_admin_user_get_internal(user_id: uuid.UUID) -> UserDB: return user +CURR_USER = Annotated[UserDB, Depends(get_user)] + + class PasswordChangeForm(BaseModel): new_password: str @router.get("/user/{user_id}") -async def api_admin_user(user_id: uuid.UUID, user: CURR_ADMIN) -> schema.User.admin_model: - ret_user = await api_admin_user_get_internal(user_id) - return ret_user +async def api_admin_user(admin: CURR_ADMIN, user: CURR_USER) -> schema.User.admin_model: + return user @router.post("/user/{user_id}") -async def api_admin_user_edit(new_user: schema.User, user_id: uuid.UUID, user: CURR_ADMIN) -> schema.User.admin_model: - new_user = await db.update_user_admin(user_id, new_user) +async def api_admin_user_edit(new_user: schema.User, user_id: uuid.UUID, admin: CURR_ADMIN) -> schema.User.admin_model: + # new_user = await db.update_user_admin(user_id, new_user) + raise Exception + return new_user @router.get("/users/me") -async def api_admin_users_me(user: CURR_ADMIN) -> schema.User.admin_model: - return user +async def api_admin_users_me(admin: CURR_ADMIN) -> schema.User.admin_model: + return admin @router.get("/users") -async def api_admin_users(user: CURR_ADMIN) -> Mapping[uuid.UUID, schema.User.admin_model]: +async def api_admin_users(admin: CURR_ADMIN) -> Mapping[uuid.UUID, schema.User.admin_model]: all_users = await api_admin_users_internal() return all_users @@ -58,7 +62,7 @@ async def api_admin_users(user: CURR_ADMIN) -> Mapping[uuid.UUID, schema.User.ad async def api_admin_user_edit_password( new_password: PasswordChangeForm, admin: CURR_ADMIN, - user: schema.User = Depends(api_admin_user_get_internal), + user: CURR_USER, ) -> schema.User.admin_model: au = user.auth_source if not isinstance(au, schema.auth.SimpleAuth.AuthModel): @@ -73,7 +77,7 @@ async def api_admin_user_edit_password( @router.get("/user/{user_id}/score") async def api_admin_user_recalc_score( admin: CURR_ADMIN, - user: UserDB = Depends(api_admin_user_get_internal), + user: CURR_USER, ) -> schema.User.admin_model: await user.recalc_score_one() return user @@ -82,18 +86,14 @@ async def api_admin_user_recalc_score( @router.delete("/user/{user_id}") async def api_admin_user_delete( admin: CURR_ADMIN, - user: schema.User = Depends(api_admin_user_get_internal), + user: CURR_USER, ) -> str: - if not user: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="user not exist", - ) + raise Exception if len(user.solved_tasks) > 0: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="user have solved tasks", ) - await db.delete_user(user) + # await db.delete_user(user) return "deleted" diff --git a/app/api/api_dynamic_tasks.py b/app/api/api_dynamic_tasks.py new file mode 100644 index 0000000..5b8dd5c --- /dev/null +++ b/app/api/api_dynamic_tasks.py @@ -0,0 +1,224 @@ +import datetime +from collections.abc import Callable +from ipaddress import ip_address +from typing import Annotated, Literal, Self, TypeAlias, cast +from uuid import UUID + +import httpx +import humanize +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from fastapi.responses import HTMLResponse +from httpx import AsyncClient +from pydantic import BaseModel, TypeAdapter + +from .. import auth, db, schema +from ..config import settings +from ..db.beanie import TaskDB, UserDB +from ..utils import metrics +from ..utils.log_helper import get_logger +from .api_tasks import CURRENT_TASK, get_task + +logger = get_logger("api.dynamic_tasks") + +router = APIRouter( + prefix="/dynamic", + tags=["dynamic_tasks"], +) + + +class DynamicTaskInfo(BaseModel): + name: str + descriptor: UUID + + type: schema.task.DynamicTaskType + + user_id: str + + flag: str + + @classmethod + def build(cls, task: schema.Task, user: schema.User) -> Self: + if not task.dynamic_task_info: + raise Exception("impossible") + + return cls( + name=f"{task.task_id}", + descriptor=task.task_id, + type=task.dynamic_task_info.dynamic_task_type, + user_id=f"{user.user_id}", + flag=task.flag.flag_value(user), + ) + + +class ExternalDynamicTaskInfo(BaseModel): + class HostPortPair(BaseModel): + host: str + port: int + + id: UUID + + task_descriptor: UUID + + user_id: str + + hp: HostPortPair + + least_time: datetime.timedelta + + +class ExternalDynamicTaskError(BaseModel): + class Detail(BaseModel): + error: str + + detail: Detail + + +class MultipleInfoRequest(BaseModel): + tasks: list[UUID] + + +class MultipleInfoResponse(BaseModel): + data: dict[UUID, str] + + +_TT: TypeAlias = ExternalDynamicTaskInfo | ExternalDynamicTaskError +ExternalDynamicTaskResp = TypeAdapter[_TT](_TT) + + +class DynamicTasksClient(AsyncClient): + def __init__(self) -> None: + logger.info("Trying to start dynamic tasks client") + + base_url = settings.DYNAMIC_TASKS_CONTROLLER or "" + x_token = settings.DYNAMIC_TASKS_CONTROLLER_TOKEN or "" + + super().__init__( + base_url=base_url, + headers={ + "X-Token": x_token, + }, + timeout=httpx.Timeout(connect=5.0, read=120.0, write=5.0, pool=5.0), + ) + logger.info(f"DTC startd with {base_url = } {x_token = }") + + def format_resp(self, resp: httpx.Response) -> str: + info = ExternalDynamicTaskResp.validate_json(resp.text) + return self.format_info(info) + + def format_info(self, info: _TT) -> str: + if not isinstance(info, ExternalDynamicTaskInfo): + return f"Status: {info.detail}" + + ret = "" + ret += "Status: Running
" + + try: + ip = ip_address(info.hp.host) + + if ip.version == 4: + ip = f"{ip}" + elif ip.version == 6: + ip = f"[{ip}]" + + except ValueError as ex: + ip = info.hp.host + + link = f"http://{ip}:{info.hp.port}/" + ret += f"{link}
" + + ret += f"Will die after {humanize.precisedelta(info.least_time)}" + + return ret + + async def start(self, task_info: DynamicTaskInfo) -> str: + resp = await self.post("/api/start", json=task_info.model_dump(mode="json")) + return self.format_resp(resp) + + async def stop(self, task_info: DynamicTaskInfo): + resp = await self.post("/api/stop", json=task_info.model_dump(mode="json")) + + if resp.status_code == status.HTTP_200_OK: + return "ok" + + try: + err = ExternalDynamicTaskError.model_validate_json(resp.text) + except Exception as ex: + return "error?" + else: + return f"Status: {err.detail}" + + async def restart(self, task_info: DynamicTaskInfo): + resp = await self.post("/api/restart", json=task_info.model_dump(mode="json")) + return self.format_resp(resp) + + async def info(self, task_info: DynamicTaskInfo) -> str: + resp = await self.post("/api/info", json=task_info.model_dump(mode="json")) + return self.format_resp(resp) + + +__client: DynamicTasksClient = DynamicTasksClient() + + +async def __get_client() -> DynamicTasksClient: + if not settings.DYNAMIC_TASKS_CONTROLLER or not settings.DYNAMIC_TASKS_CONTROLLER_TOKEN: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="dynamic tasks not enabled", + ) + return __client + + +async def get_dynamic_task(task: CURRENT_TASK) -> TaskDB: + if not task.dynamic_task_info: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Bad task", + ) + return task + + +CLIENT = Annotated[DynamicTasksClient, Depends(__get_client)] +CURRENT_DYNAMIC_TASK = Annotated[TaskDB, Depends(get_dynamic_task)] + + +@router.get("/start/{task_id}") +async def api_dynamic_task_start(user: auth.CURR_USER, task: CURRENT_DYNAMIC_TASK, client: CLIENT) -> HTMLResponse: + info = await client.start(DynamicTaskInfo.build(task=task, user=user)) + return HTMLResponse(info) + + +@router.get("/stop/{task_id}") +async def api_dynamic_task_stop(user: auth.CURR_USER, task: CURRENT_DYNAMIC_TASK, client: CLIENT) -> HTMLResponse: + info = await client.stop(DynamicTaskInfo.build(task=task, user=user)) + return HTMLResponse(info) + + +@router.get("/restart/{task_id}") +async def api_dynamic_task_restart(user: auth.CURR_USER, task: CURRENT_DYNAMIC_TASK, client: CLIENT) -> HTMLResponse: + info = await client.restart(DynamicTaskInfo.build(task=task, user=user)) + return HTMLResponse(info) + + +@router.post("/infos") +async def api_dynamic_task_infos( + user: auth.CURR_USER, + client: CLIENT, + req: MultipleInfoRequest, +) -> MultipleInfoResponse: + resp = MultipleInfoResponse(data={}) + + for task_id in req.tasks: + try: + task = await get_dynamic_task(await get_task(task_id=task_id, user=user)) + except HTTPException: + continue + + resp.data[task_id] = await client.info(DynamicTaskInfo.build(task=task, user=user)) + + return resp + + +@router.get("/info/{task_id}") +async def api_dynamic_task_info(user: auth.CURR_USER, task: CURRENT_DYNAMIC_TASK, client: CLIENT) -> HTMLResponse: + info = await client.info(DynamicTaskInfo.build(task=task, user=user)) + return HTMLResponse(info) diff --git a/app/api/api_tasks.py b/app/api/api_tasks.py index 31e534d..64a2d86 100644 --- a/app/api/api_tasks.py +++ b/app/api/api_tasks.py @@ -1,5 +1,6 @@ import uuid from datetime import UTC, datetime +from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, status @@ -16,6 +17,19 @@ ) +async def get_task(task_id: uuid.UUID, user: auth.CURR_USER_SAFE) -> TaskDB: + task = await TaskDB.find_by_task_uuid(task_id) + if not task or not task.visible_for_user(user): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No task", + ) + return task + + +CURRENT_TASK = Annotated[TaskDB, Depends(get_task)] + + @router.get("/") async def api_tasks_get(user: auth.CURR_USER_SAFE) -> list[schema.Task.public_model]: tasks = await TaskDB.get_all() @@ -31,6 +45,11 @@ class BRMessage(schema.EBaseModel): is_fb: bool +@router.get("/{task_id}") +async def api_task_get(task: CURRENT_TASK) -> schema.Task.public_model: + return task + + @router.post("/submit_flag") async def api_task_submit_flag(flag: schema.FlagForm, user: auth.CURR_USER) -> uuid.UUID: if datetime.now(tz=UTC) < settings.EVENT_START_TIME: @@ -103,14 +122,3 @@ async def api_task_submit_flag(flag: schema.FlagForm, user: auth.CURR_USER) -> u logger.error(f"tg_exception exception='{ex}'") return ret - - -@router.get("/{task_id}") -async def api_task_get(task_id: uuid.UUID, user: auth.CURR_USER_SAFE) -> schema.Task.public_model: - task = await TaskDB.find_by_task_uuid(task_id) - if not task or not task.visible_for_user(user): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="No task", - ) - return task diff --git a/app/api/api_users.py b/app/api/api_users.py index a022a33..fa0faf9 100644 --- a/app/api/api_users.py +++ b/app/api/api_users.py @@ -1,6 +1,6 @@ import uuid -from collections.abc import Sequence -from typing import Iterable, TypeVar +from collections.abc import Iterable, Sequence +from typing import TypeVar from fastapi import APIRouter, Depends, HTTPException, Request, Response, status diff --git a/app/cli/base.py b/app/cli/base.py index 8a215ed..c3fdc8f 100644 --- a/app/cli/base.py +++ b/app/cli/base.py @@ -1,16 +1,14 @@ import typer -from pydantic_settings import BaseSettings +from pydantic_settings import BaseSettings, SettingsConfigDict from rich.console import Console class Settings(BaseSettings): - files_url: str = "http://127.0.0.1:9999" - base_url: str = "http://127.0.0.1:8080" - - tasks_ip: str = "127.0.0.1" - - tasks_domain: str = "tasks.kksctf.ru" - flag_base: str = "kks" + model_config = SettingsConfigDict( + env_file="yatb.env", + env_file_encoding="utf-8", + extra="allow", + ) settings = Settings() diff --git a/app/cli/client.py b/app/cli/client.py index 1ba0f81..baf9ed4 100644 --- a/app/cli/client.py +++ b/app/cli/client.py @@ -96,11 +96,21 @@ async def create_task(self, task: RawTask) -> schema.Task: flag=schema.flags.StaticFlag(flag=task.flag, flag_base=settings.flag_base), scoring=schema.scoring.DynamicKKSScoring(), author=task.author, + dynamic_task_info=task.dynamic_task_type, ).model_dump(mode="json"), ) ).json() return schema.Task.model_validate(new_task) + async def create_task_full_form(self, task: schema.TaskForm) -> schema.Task: + new_task = ( + await self.s.post( + app.url_path_for("api_admin_task_create"), + json=task.model_dump(mode="json"), + ) + ).json() + return schema.Task.model_validate(new_task) + async def admin_recalc_scoreboard(self) -> None: resp = await self.s.get(app.url_path_for("api_admin_recalc_scoreboard")) resp.raise_for_status() diff --git a/app/cli/cmd/__init__.py b/app/cli/cmd/__init__.py index f8dd9a2..5849aa0 100644 --- a/app/cli/cmd/__init__.py +++ b/app/cli/cmd/__init__.py @@ -16,9 +16,9 @@ from . import load as load_cmds from . import stress as stress_cmds -get_cmds = get_cmds -stress_cmds = stress_cmds -load_cmds = load_cmds +get_cmds = get_cmds # noqa: PLW0127 +stress_cmds = stress_cmds # noqa: PLW0127 +load_cmds = load_cmds # noqa: PLW0127 tasks_to_create: list[RawTask] = [ RawTask( diff --git a/app/cli/cmd/load.py b/app/cli/cmd/load.py index 27d3ed5..394680f 100644 --- a/app/cli/cmd/load.py +++ b/app/cli/cmd/load.py @@ -1,4 +1,5 @@ import asyncio +import json import shutil import subprocess from pathlib import Path @@ -6,61 +7,59 @@ from pydantic_yaml import parse_yaml_raw_as -from ...schema.task import Task +from ... import schema +from ...schema.task import DynamicTaskInfo, DynamicTaskType, Task, TaskForm from ..base import c, settings, tapp from ..client import YATB -from ..models import FileTask +from ..models import FileTask, RawTask, State + + +def task_to_raw(task: FileTask) -> TaskForm: + name = task.full_name + description = task.description.strip().strip('"').strip("'") + + flag = task.flag + if flag.startswith(settings.flag_base + "{") and flag.endswith("}"): + flag = flag.removeprefix(settings.flag_base + "{") + flag = flag.removesuffix("}") + + return TaskForm( + task_name=name, + category=task.category, + description=description, + author=task.author, + dynamic_task_info=DynamicTaskInfo( + dynamic_task_type=DynamicTaskType.SERVICE, # TODO: builder + ), + flag=schema.flags.DynamicKKSFlag(dynamic_flag_base=flag, flag_base=settings.flag_base), + scoring=schema.scoring.DynamicKKSScoring(), + ) @tapp.command() def prepare_tasks( main_tasks_dir: Path, - static_files_dir: Path, - deploy_files_dir: Path, + # static_files_dir: Path, + # deploy_files_dir: Path, *, drop: bool = False, - live: bool = True, + # live: bool = True, + state_path: Path = Path() / "yatb_state.json", ): + state = State.model_validate_json(state_path.read_text()) if state_path.exists() else State() + main_tasks_dir = main_tasks_dir.expanduser().resolve() - static_files_dir = static_files_dir.expanduser().resolve() - deploy_files_dir = deploy_files_dir.expanduser().resolve() async def _a(): - caddy_data = "" - used_prefix: set[str] = set() - async with YATB() as y: y.set_admin_token() if drop: - if static_files_dir.exists(): - c.log("Cleaning static files...") - shutil.rmtree(static_files_dir) - - if deploy_files_dir.exists(): - c.log("Cleaning deploy files...") - shutil.rmtree(deploy_files_dir) - await y.detele_everything() + state.task_to_uuid.clear() - if live and deploy_files_dir.exists(): - c.log("Cleaning deploy files...") - shutil.rmtree(deploy_files_dir) - - static_files_dir.mkdir(parents=True, exist_ok=True) - deploy_files_dir.mkdir(parents=True, exist_ok=True) - - old_tasks: dict[UUID, Task] = {} - - def search_task(target: FileTask) -> Task | None: - for task in old_tasks.values(): - if target.full_name == task.task_name: - return task - return None - - if live: - old_tasks = await y.get_all_tasks() - c.print(f"Running in live mode, found {len(old_tasks)} tasks") + tasks_cache: dict[UUID, Task] = await y.get_all_tasks() + c.print(f"Running in live mode, found {len(tasks_cache)} tasks") for category_src in main_tasks_dir.iterdir(): if not category_src.is_dir(): @@ -73,74 +72,26 @@ def search_task(target: FileTask) -> Task | None: if not (task_src / "task.yaml").exists(): continue - task_info = parse_yaml_raw_as(FileTask, (task_src / "task.yaml").read_text()) - - board_task = search_task(task_info) - if live and not board_task: - c.print(f"WTF: {task_info = } not found") - input("wtf?") - - created_task = board_task or await y.create_task(task_info.get_raw()) - c.print(f"Created task: {created_task}") - - public_dir = task_src / "public" - if public_dir.exists() and not live: - task_files_dir = static_files_dir / str(created_task.task_id) - task_files_dir.mkdir(parents=True, exist_ok=True) - - files = list(public_dir.iterdir()) - files_hash = subprocess.check_output( # noqa: ASYNC101 - "sha256sum -b public/*", # noqa: S607 - shell=True, # noqa: S602 - cwd=task_src, - stderr=subprocess.STDOUT, - ) - - created_task.description += "\n\n---\n\n" - created_task.description += '
' - - for file in files: - created_task.description += ( - f"{file.name}\n" - ) - shutil.copy2(file, task_files_dir) - c.print(f"\t\t[+] '{created_task.task_name}': uploaded file {file}") - - (task_files_dir / ".sha256").write_bytes(files_hash) - created_task.description += ( - f".sha256\n" - ) - - created_task.description = created_task.description.strip() + "
" - - created_task = await y.update_task(task=created_task) - - c.print(f"Updated task: {created_task}") - - if ( - task_info.server_port - and task_info.is_http - and (prefix := task_info.domain_prefix) - and prefix not in used_prefix - ): - caddy_data += ( - f"@task-{prefix} host {prefix}.{settings.tasks_domain}\n" - f"handle @task-{prefix} {{\n" - f" reverse_proxy 127.0.0.1:{task_info.server_port}\n" - "}\n\n" - ) - used_prefix.add(prefix) - - deploy_dir = task_src / "deploy" - if deploy_dir.exists(): - target_deploy_dir = deploy_files_dir / task_src.name - shutil.copytree(deploy_dir, target_deploy_dir) - - c.print("Caddy data:") - c.print(caddy_data) - - asyncio.run(_a()) + try: + task_info = parse_yaml_raw_as(FileTask, (task_src / "task.yaml").read_text()) + except Exception as ex: + c.print(f"ERROR!!! {task_src = } has bad yaml: {ex!r}") + continue + + if task_src not in state.task_to_uuid: + created_task = await y.create_task_full_form(task_to_raw(task_info)) + tasks_cache[created_task.task_id] = created_task + state.task_to_uuid[task_src] = created_task.task_id + c.print(f"Created task: {created_task}") + continue + + created_task = tasks_cache[state.task_to_uuid[task_src]] + c.print(f"Found task: {created_task}") + + to_env = {str(uuid): str((path / "deploy").resolve()) for path, uuid in state.task_to_uuid.items()} + c.print(json.dumps(to_env, indent=4)) + + try: + asyncio.run(_a()) + finally: + state_path.write_text(state.model_dump_json(indent=4)) diff --git a/app/cli/models.py b/app/cli/models.py index 5236238..1ae5ebd 100644 --- a/app/cli/models.py +++ b/app/cli/models.py @@ -3,6 +3,7 @@ import uuid from dataclasses import dataclass from datetime import datetime +from pathlib import Path from pydantic import BaseModel, RootModel @@ -41,6 +42,8 @@ class RawTask: author: str = "" + dynamic_task_type: schema.DynamicTaskInfo | None = None + class FileTask(BaseModel): name: str @@ -50,9 +53,7 @@ class FileTask(BaseModel): category: str flag: str - is_gulag: bool = False - warmup: bool = False server_port: int | None = None is_http: bool = True @@ -62,45 +63,9 @@ class FileTask(BaseModel): def full_name(self) -> str: return self.name - def get_raw(self) -> RawTask: - name = self.full_name - - description = self.description.strip().strip('"').strip("'") - - if self.server_port: - if self.is_http: - if self.domain_prefix: - server_addr = f"https://{self.domain_prefix}.{settings.tasks_domain}/" - else: - server_addr = f"http://{settings.tasks_ip}:{self.server_port}" - - description += "\n\n---\n\n" - description += '
' - description += ( - f"{server_addr}\n" - ) - description += "
" - else: - description += ( - "\n\n---\n\n" - f"`nc {settings.tasks_ip} {self.server_port}`" - "\n" # - ) - - flag = self.flag - if flag.startswith(settings.flag_base + "{") and flag.endswith("}"): - flag = flag.removeprefix(settings.flag_base + "{") - flag = flag.removesuffix("}") - - return RawTask( - task_name=name, - category=self.category, - description=description, - flag=flag, - author=self.author, - ) + +class State(BaseModel): + task_to_uuid: dict[Path, uuid.UUID] = {} AllUsers = RootModel[dict[uuid.UUID, UserPrivate]] diff --git a/app/config.py b/app/config.py index cb3d7f7..d0414b4 100644 --- a/app/config.py +++ b/app/config.py @@ -15,6 +15,8 @@ class DefaultTokenError(ValueError): class Settings(BaseSettings): DEBUG: bool = False + TESTING: bool = False + PROFILING: bool = False TOKEN_PATH: str = "/api/users/login" @@ -63,9 +65,12 @@ class Settings(BaseSettings): "DiscordOAuth", ] + DYNAMIC_TASKS_CONTROLLER: str | None = None + DYNAMIC_TASKS_CONTROLLER_TOKEN: str | None = None + @model_validator(mode="after") def check_non_default_tokens(self) -> Self: - if self.DEBUG: + if self.DEBUG or self.TESTING: return self token_check_list = ["JWT_SECRET_KEY", "FLAG_SIGN_KEY", "API_TOKEN", "WS_API_TOKEN"] diff --git a/app/db/__init__.py b/app/db/__init__.py index 8ba57f2..e69de29 100644 --- a/app/db/__init__.py +++ b/app/db/__init__.py @@ -1,115 +0,0 @@ -import os -import pickle -from datetime import datetime - -from pydantic import BaseModel - -from .. import app, db, schema -from ..config import settings -from ..utils.log_helper import get_logger -from .beanie import TaskDB, UserDB - -logger = get_logger("db") - - -class FileDB: - _db = None - _index = None - - def __init__(self): - self.reset_db() - - def reset_db(self): - self._index = { - "tasks": {}, - "users": {}, - "short_urls": {}, - } - self._db = { - "tasks": {}, - "users": {}, - } - - def generate_index(self): - for i, v in self._db["tasks"].items(): - self._index["tasks"][v.task_id] = self.update_task(v) - - for i, v in self._db["users"].items(): - self._index["users"][v.user_id] = self.update_user(v) - - def update_task(self, task: schema.Task): - # regenerate markdown - task.description_html = schema.Task.regenerate_md(task.description) - - return task - - def update_user(self, user: schema.User): - # FIXME: говнокод & быстрофикс. - if isinstance(user.auth_source, dict): - original_au = user.auth_source - cls: schema.auth.AuthBase.AuthModel = getattr(schema.auth, user.auth_source["classtype"]).AuthModel - user.auth_source = cls.model_validate(user.auth_source) - logger.warning(f"Found & fixed broken auth source: {original_au} -> {user.auth_source}") - - # admin promote - if user.admin_checker() and not user.is_admin: - logger.warning(f"INIT: Promoting {user} to admin") - user.is_admin = True - - return user - - -_db = FileDB() - - -@app.on_event("startup") -async def startup_event(): - return - global _db - if settings.DB_NAME is None: - _db.reset_db() - logger.warning("TESTING_FileDB loaded") - return - - if not os.path.exists(settings.DB_NAME): - _db._db = { - "tasks": {}, - "users": {}, - } - else: - try: - with open(settings.DB_NAME, "rb") as f: - _db._db = pickle.load(f) - except Exception as ex: - _db._db = { - "tasks": {}, - "users": {}, - } - logger.error(f"Loading db exception, fallback to empty, {ex}") - - _db.generate_index() - logger.warning("FileDB loaded") - # logger.debug(f"FileDB: {_db._db}") - # logger.debug(f"FileDBIndex: {_db._index}") - - -@app.on_event("shutdown") -async def shutdown_event(): - return - global _db - if settings.DB_NAME is None: - return - save_path = settings.DB_NAME / "ressurect_db.db" if settings.DB_NAME.is_dir() else settings.DB_NAME - with open(settings.DB_NAME, "wb") as f: - pickle.dump(_db._db, f) - logger.warning("FileDB saved") - - -def update_entry(obj: BaseModel, data: dict): - for i in data: - if i in obj.__fields__: - setattr(obj, i, data[i]) - - -# from .db_tasks import * # noqa -# from .db_users import * # noqa diff --git a/app/db/beanie.py b/app/db/beanie.py index 2b1785e..7a25b17 100644 --- a/app/db/beanie.py +++ b/app/db/beanie.py @@ -1,19 +1,16 @@ import datetime import uuid from collections.abc import Hashable, Mapping -from typing import Annotated, Any, ClassVar, Generic, Literal, Self, TypeVar, final +from typing import Any, ClassVar, Generic, Literal, Self, TypeVar, final -import bson import pymongo from beanie import BulkWriter, Document, init_beanie from beanie.operators import And as _And from beanie.operators import Set from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase -from pydantic import PlainSerializer -from .. import app from ..config import settings -from ..schema import EBaseModel, Task, TaskForm, User, auth +from ..schema import EBaseModel, FlagCheckResult, Task, TaskForm, User, auth from ..schema.ebasemodel import FilterFieldsType from ..utils.log_helper import get_logger @@ -83,16 +80,8 @@ async def update_entry(self, new_task: Task) -> Self: return self @classmethod - async def populate(cls: type[Self], new_task: TaskForm, author: User) -> Self: - task = cls( - task_name=new_task.task_name, - category=new_task.category, - scoring=new_task.scoring, - description=new_task.description, - description_html=Task.regenerate_md(new_task.description), - flag=new_task.flag, - author=(new_task.author if new_task.author != "" else f"@{author.username}"), - ) + async def populate(cls, new_task: TaskForm, author: User) -> Self: + task = new_task.to_task(cls, author) await task.insert() # type: ignore # WTF: bad library return task @@ -107,9 +96,15 @@ async def get_all(cls: type[Self]) -> dict[uuid.UUID, Self]: @classmethod async def find_by_flag(cls: type[Self], flag: str, user: User) -> Self | None: for task in await cls.find_all().to_list(): - if task.flag.flag_checker(flag, user): + result = task.flag.flag_checker(flag, user) + if result == FlagCheckResult.valid: return task + if result == FlagCheckResult.invalid: + continue + + logger.warning(f"user=[{user.short_desc()}], task=[{task.short_desc()}], {flag=}, {result.name=}") + return None @classmethod @@ -311,34 +306,18 @@ def __init__(self) -> None: async def init(self) -> None: self.client = AsyncIOMotorClient(str(settings.MONGO), tz_aware=True) self.db = self.client[settings.DB_NAME] - await init_beanie(database=self.db, document_models=[TaskDB, UserDB]) # type: ignore # bad library ;( + await init_beanie(database=self.db, document_models=[TaskDB, UserDB]) logger.info("Beanie init ok") async def close(self) -> None: logger.info("DB close ok") async def reset_db(self) -> None: - if not settings.DEBUG: - logger.warning("DB Reset without debug") + if not (settings.DEBUG or settings.TESTING): + logger.warning(f"DB Reset without debug ({settings.DEBUG = }) or testing {settings.TESTING = }") return await self.client.drop_database(settings.DB_NAME) -@app.on_event("startup") -async def startup_event(): - await db.init() - - -@app.on_event("shutdown") -async def shutdown_event(): - await db.close() - - -# async def init_db(): -# await db.init() -# yield -# await db.close() - - db = DBClient() diff --git a/app/db/db_tasks.py b/app/db/db_tasks.py deleted file mode 100644 index cc62900..0000000 --- a/app/db/db_tasks.py +++ /dev/null @@ -1,174 +0,0 @@ -import datetime -import logging -import uuid -from asyncio import Lock -from typing import Dict, Union - -from .. import schema -from ..config import settings -from ..utils import metrics -from ..utils.log_helper import get_logger -from . import db_users, update_entry - -# import markdown2 - - -logger = get_logger("db.tasks") -db_lock = Lock() - - -async def get_task_uuid(uuid: uuid.UUID) -> schema.Task: - from . import _db - - if uuid in _db._index["tasks"]: - return _db._index["tasks"][uuid] - - -async def get_all_tasks() -> Dict[uuid.UUID, schema.Task]: - from . import _db - - return _db._index["tasks"] - - -async def check_task_uuid(uuid: uuid.UUID) -> bool: - from . import _db - - return uuid in _db._index["tasks"] - - -async def insert_task(new_task: schema.TaskForm, author: schema.User) -> schema.Task: - from . import _db - - # task = schema.Task.parse_obj(new_task) # WTF: SHITCODE - task = schema.Task( - task_name=new_task.task_name, - category=new_task.category, - scoring=new_task.scoring, - description=new_task.description, - description_html=schema.Task.regenerate_md(new_task.description), - flag=new_task.flag, - author=(new_task.author if new_task.author != "" else f"@{author.username}"), - ) - - _db._db["tasks"][task.task_id] = task - _db._index["tasks"][task.task_id] = task - return task - - -async def update_task(task: schema.Task, new_task: schema.Task) -> schema.Task: - from . import _db - - logger.debug(f"Update task {task} to {new_task}") - - update_entry( - task, - new_task.dict( - exclude={ - "task_id", - "description_html", - "scoring", - "flag", - "pwned_by", - } - ), - ) - task.scoring = new_task.scoring # fix for json-ing scoring on edit - task.flag = new_task.flag # fix for json-ing flag on edit - - logger.debug(f"Resulting task={task}") - task.description_html = schema.Task.regenerate_md(task.description) - return task - - -async def remove_task(task: schema.Task): - from . import _db - - # TODO: recalc score and something else. - await unsolve_task(task) - del _db._db["tasks"][task.task_id] - del _db._index["tasks"][task.task_id] - - -async def find_task_by_flag(flag: str, user: schema.User) -> Union[schema.Task, None]: - from . import _db - - for task_id, task in _db._db["tasks"].items(): - task: schema.Task # strange solution, but no other ideas - if task.flag.flag_checker(flag, user): - return task - - return None - - -async def solve_task(task: schema.Task, solver: schema.User): - if solver.is_admin and not settings.DEBUG: # if you admin, you can't solve task. - return task.task_id - - if datetime.datetime.now(tz=datetime.UTC) > settings.EVENT_END_TIME: - return task.task_id - - # WTF: UNTEDTED: i belive this will work as a monkey patch for rAcE c0nDiTioN - global db_lock - async with db_lock: - # add references - solv_time = datetime.datetime.now(tz=datetime.UTC) - solver.solved_tasks[task.task_id] = solv_time - task.pwned_by[solver.user_id] = solv_time - - # get previous score - prev_score = task.scoring.points - solver.score += prev_score - - # if do_recalc, recalc all the scoreboard... only users, who solved task - do_recalc = task.scoring.solve_task() - if do_recalc: - new_score = task.scoring.points - diff = prev_score - new_score - logger.info(f"Solve task: {task.short_desc()}, oldscore={prev_score}, newscore={new_score}, diff={diff}") - for solver_id in task.pwned_by: - solver_recalc = await db_users.get_user_uuid(solver_id) - solver_recalc.score -= diff - metrics.score_per_user.labels(user_id=solver_recalc.user_id, username=solver_recalc.username).set( - solver_recalc.score - ) - - return task.task_id - - -async def unsolve_task(task: schema.Task) -> schema.Task: - # add references - global db_lock - async with db_lock: - task.pwned_by.clear() - # TODO: оптимизировать эту ебатеку - for _, user in (await db_users.get_all_users()).items(): - if task.task_id in user.solved_tasks: - user.solved_tasks.pop(task.task_id) - # task.scoring - - await recalc_scoreboard() - return task - - -async def recalc_user_score(user: schema.User, _task_cache: Dict[uuid.UUID, schema.Task] = None): - if _task_cache is None: - _task_cache = {} - old_score = user.score - user.score = 0 - for task_id in user.solved_tasks: - if task_id not in _task_cache: - _task_cache[task_id] = await get_task_uuid(task_id) - if _task_cache[task_id] is None: - continue - user.score += _task_cache[task_id].scoring.points - _task_cache[task_id].scoring.set_solves(len(_task_cache[task_id].pwned_by)) - if old_score != user.score: - logger.warning(f"Recalc: smth wrong with {user.short_desc()}, {old_score} != {user.score}!") - - -async def recalc_scoreboard(): - _task_cache: Dict[uuid.UUID, schema.Task] = {} - global db_lock - async with db_lock: - for _, user in (await db_users.get_all_users()).items(): - await recalc_user_score(user, _task_cache) diff --git a/app/db/db_users.py b/app/db/db_users.py deleted file mode 100644 index e58347a..0000000 --- a/app/db/db_users.py +++ /dev/null @@ -1,129 +0,0 @@ -from app.db import update_entry -import uuid -import logging -from typing import Hashable, List, Dict, Optional, Type - -from .. import schema -from ..utils.log_helper import get_logger - -logger = get_logger("db.users") - -# logger.debug(f"GlobalUsers, FileDB: {_db}") - - -async def get_user(username: str) -> Optional[schema.User]: - from . import _db - - for i in _db._index["users"]: - if _db._index["users"][i].username == username: - return _db._index["users"][i] - return None - - -async def get_user_uuid(uuid: uuid.UUID) -> Optional[schema.User]: - from . import _db - - if uuid in _db._index["users"]: - return _db._index["users"][uuid] - - -async def get_user_uniq_field(base: Type[schema.auth.AuthBase.AuthModel], field: Hashable) -> schema.User: - from . import _db - - for i in _db._index["users"]: - if ( - type(_db._index["users"][i].auth_source) == base - and _db._index["users"][i].auth_source.get_uniq_field() == field - ): - return _db._index["users"][i] - return None - - -async def get_all_users() -> Dict[uuid.UUID, schema.User]: - from . import _db - - return _db._db["users"] - - -async def check_user(username: str) -> bool: - from . import _db - - for i in _db._index["users"]: - if _db._index["users"][i].username == username: - return True - return False - - -async def check_user_uuid(uuid: uuid.UUID) -> bool: - from . import _db - - return uuid in _db._index["users"] - - -async def check_user_uniq_field(base: Type[schema.auth.AuthBase.AuthModel], field: Hashable) -> bool: - from . import _db - - for i in _db._index["users"]: - if ( - type(_db._index["users"][i].auth_source) == base - and _db._index["users"][i].auth_source.get_uniq_field() == field - ): - return True - return False - - -async def insert_user(auth: schema.auth.TYPING_AUTH): - from . import _db - - # WTF: SHITCODE or not.... :thonk: - user = schema.User(auth_source=auth) - _db._db["users"][user.user_id] = user - _db._index["users"][user.user_id] = user - return user - - -""" -async def insert_oauth_user(oauth_id: int, username: str, country: str): - from . import _db - - # WTF: SHITCODE - user = schema.User( - username=username, - password_hash=None, - country=country, - oauth_id=oauth_id, - ) - _db._db["users"][user.user_id] = user - _db._index["users"][user.user_id] = user - return user -""" - - -async def update_user_admin(user_id: uuid.UUID, new_user: schema.User): - from . import _db - - user: schema.User = _db._index["users"][user_id] - logger.debug(f"Update user {user} to {new_user}") - - update_entry( - user, - new_user.dict( - exclude={ - "user_id", - "password_hash", - "score", - "solved_tasks", - "oauth_id", - } - ), - ) - # user.parse_obj(new_user) - logger.debug(f"Resulting user={user}") - return user - - -async def delete_user(user: schema.User): - from . import _db - - del _db._db["users"][user.user_id] - del _db._index["users"][user.user_id] diff --git a/app/main.py b/app/main.py index 61bd116..89b24e6 100644 --- a/app/main.py +++ b/app/main.py @@ -1,68 +1,73 @@ -from . import app, root_logger -from .api import api_tasks +import time + +from fastapi import FastAPI, Request + from .config import settings +from .utils.log_helper import root_logger + -""" -@app.middleware("http") -async def session_middleware(request: Request, call_next): - # start_time = time.time() +async def simple_timing_middleware(request: Request, call_next): + start_time = time.time() response = await call_next(request) - # process_time = time.time() - start_time - # response.headers["X-Process-Time"] = str(process_time) + process_time = time.time() - start_time + response.headers["X-Process-Time"] = str(process_time) return response -""" -if settings.DEBUG: - try: - import fastapi # noqa - import pydantic # noqa - from asgi_server_timing import ServerTimingMiddleware # noqa # type: ignore - root_logger.warning("Timing debug loading") +def setup_utils(app: FastAPI): + if settings.DEBUG: + app.middleware("http")(simple_timing_middleware) + + try: + import fastapi # noqa + import pydantic # noqa + from asgi_server_timing import ServerTimingMiddleware # noqa # type: ignore + + root_logger.warning("Timing debug loading") - app.add_middleware( - ServerTimingMiddleware, - calls_to_track={ - "1deps": (fastapi.routing.solve_dependencies,), # type: ignore - "2main": (fastapi.routing.run_endpoint_function,), - # "3valid": (pydantic.fields.ModelField.validate,), - "4encode": (fastapi.encoders.jsonable_encoder,), # type: ignore - "5render": ( - fastapi.responses.JSONResponse.render, - fastapi.responses.ORJSONResponse.render, - fastapi.responses.HTMLResponse.render, - fastapi.responses.PlainTextResponse.render, - ), - # "6tasks": (api_tasks.api_tasks_get,), - # "6task": (api_tasks.api_task_get,), - }, - ) - root_logger.warning("Timing debug loaded") + app.add_middleware( + ServerTimingMiddleware, + calls_to_track={ + "1deps": (fastapi.routing.solve_dependencies,), # type: ignore + "2main": (fastapi.routing.run_endpoint_function,), + # "3valid": (pydantic.fields.ModelField.validate,), + "4encode": (fastapi.encoders.jsonable_encoder,), # type: ignore + "5render": ( + fastapi.responses.JSONResponse.render, + fastapi.responses.ORJSONResponse.render, + fastapi.responses.HTMLResponse.render, + fastapi.responses.PlainTextResponse.render, + ), + # "6tasks": (api_tasks.api_tasks_get,), + # "6task": (api_tasks.api_task_get,), + }, + ) + root_logger.warning("Timing debug loaded") - except ModuleNotFoundError: - root_logger.warning("No timing extensions found") + except ModuleNotFoundError: + root_logger.warning("No timing extensions found") -if settings.PROFILING: - try: - from fastapi import Request - from fastapi.responses import HTMLResponse - from pyinstrument import Profiler + if settings.PROFILING: + try: + from fastapi import Request + from fastapi.responses import HTMLResponse + from pyinstrument import Profiler - root_logger.warning("pyinstrument loading") + root_logger.warning("pyinstrument loading") - @app.middleware("http") - async def profile_request(request: Request, call_next): - profiling = request.query_params.get("profile", False) - if profiling: - profiler = Profiler(async_mode="enabled") # interval=settings.profiling_interval - profiler.start() - await call_next(request) - profiler.stop() - return HTMLResponse(profiler.output_html()) - else: - return await call_next(request) + @app.middleware("http") + async def profile_request(request: Request, call_next): + profiling = request.query_params.get("profile", False) + if profiling: + profiler = Profiler(async_mode="enabled") # interval=settings.profiling_interval + profiler.start() + await call_next(request) + profiler.stop() + return HTMLResponse(profiler.output_html()) + else: + return await call_next(request) - root_logger.warning("pyinstrument loaded") + root_logger.warning("pyinstrument loaded") - except ModuleNotFoundError: - root_logger.warning("No pyinstrument found") + except ModuleNotFoundError: + root_logger.warning("No pyinstrument found") diff --git a/app/schema/__init__.py b/app/schema/__init__.py index 419aa71..0b3479d 100644 --- a/app/schema/__init__.py +++ b/app/schema/__init__.py @@ -1,9 +1,9 @@ from ..utils.log_helper import get_logger from .auth import AuthBase, CTFTimeOAuth, OAuth, SimpleAuth, TelegramAuth from .ebasemodel import EBaseModel -from .flags import DynamicKKSFlag, Flag, StaticFlag +from .flags import DynamicKKSFlag, Flag, FlagCheckResult, StaticFlag from .scoring import DynamicKKSScoring, Scoring, StaticScoring -from .task import FlagUnion, ScoringUnion, Task, TaskForm +from .task import DynamicTaskInfo, DynamicTaskType, FlagUnion, ScoringUnion, Task, TaskForm from .user import User logger = get_logger("schema") diff --git a/app/schema/auth/simple.py b/app/schema/auth/simple.py index f3b7271..c76ca44 100644 --- a/app/schema/auth/simple.py +++ b/app/schema/auth/simple.py @@ -63,12 +63,13 @@ def check_password(self, model: "SimpleAuth.AuthModel") -> bool: def check_valid(self) -> bool: if settings.DEBUG: return True + if ( len(self.internal.username) < SimpleAuth.auth_settings.MIN_USERNAME_LEN or len(self.internal.username) > SimpleAuth.auth_settings.MAX_USERNAME_LEN ): return False - if len(self.internal.password) < SimpleAuth.auth_settings.MIN_PASSWORD_LEN: + if len(self.internal.password) < SimpleAuth.auth_settings.MIN_PASSWORD_LEN: # noqa: SIM103 return False return True diff --git a/app/schema/ebasemodel.py b/app/schema/ebasemodel.py index b90d371..0204ffe 100644 --- a/app/schema/ebasemodel.py +++ b/app/schema/ebasemodel.py @@ -62,7 +62,7 @@ def build_model( # noqa: PLR0912, C901 # WTF: refactor & simplify new_union = Union[tuple(new_union_base)] # type: ignore # noqa: UP007, PGH003 # так надо. - target_fields[field_name] = ( + target_fields[field_name] = ( # type: ignore # WTF: ??? new_union, field_value, ) diff --git a/app/schema/flags.py b/app/schema/flags.py index f4d2e2d..914b7c2 100644 --- a/app/schema/flags.py +++ b/app/schema/flags.py @@ -1,5 +1,6 @@ import binascii import hmac +from enum import Enum, auto from typing import ClassVar, Literal from ..config import settings @@ -7,6 +8,12 @@ from .user import User +class FlagCheckResult(Enum): + invalid = auto() + valid = auto() + invalid_sign = auto() + + class Flag(EBaseModel): __public_fields__: ClassVar = {"classtype"} __admin_only_fields__: ClassVar = {"flag_base"} @@ -21,16 +28,16 @@ def sanitization(self, user_flag: str) -> str: user_flag = user_flag[:-1] user_flag = self.flag_base + "{" + user_flag + "}" - return user_flag + return user_flag # noqa: RET504 def flag_value(self, user: User) -> str: return self.flag_base + "{test_flag}" - def flag_checker(self, user_flag: str, user: User) -> bool: + def flag_checker(self, user_flag: str, user: User) -> FlagCheckResult: if self.flag_value(user) == self.sanitization(user_flag): - return True - else: - return False + return FlagCheckResult.valid + + return FlagCheckResult.invalid class StaticFlag(Flag): @@ -49,7 +56,24 @@ class DynamicKKSFlag(Flag): classtype: Literal["DynamicKKSFlag"] = "DynamicKKSFlag" dynamic_flag_base: str - def flag_value(self, user: User) -> str: + def flag_parts(self, user: User) -> tuple[str, str]: flag_part = "{" + self.dynamic_flag_base + "}" + f"{user.user_id}" hash = hmac.digest(settings.FLAG_SIGN_KEY.encode(), flag_part.encode(), "sha256") - return self.flag_base + "{" + self.dynamic_flag_base + "_" + binascii.hexlify(hash).decode()[0:14] + "}" + return self.dynamic_flag_base, binascii.hexlify(hash).decode()[0:14] + + def flag_value(self, user: User) -> str: + base, hash = self.flag_parts(user) + return self.flag_base + "{" + base + "_" + hash + "}" + + def flag_checker(self, user_flag: str, user: User) -> FlagCheckResult: + sanitized_flag = self.sanitization(user_flag) + if self.flag_value(user) == sanitized_flag: + return FlagCheckResult.valid + + # some of copypaste, but i have no idea how to make this without copypaste + base, hash = self.flag_parts(user) + prefix = self.flag_base + "{" + base + if sanitized_flag.startswith(prefix): + return FlagCheckResult.invalid_sign + + return FlagCheckResult.invalid diff --git a/app/schema/task.py b/app/schema/task.py index 34d3b5b..c341747 100644 --- a/app/schema/task.py +++ b/app/schema/task.py @@ -1,9 +1,11 @@ import datetime import uuid +from enum import Enum from typing import Annotated, ClassVar from zoneinfo import ZoneInfo -from pydantic import Field +from pydantic import Field, computed_field +from typing_extensions import TypeVar from .. import config from ..config import settings @@ -38,6 +40,18 @@ def template_format_time(date: datetime.datetime) -> str: # from alb1or1x_shit. ] +class DynamicTaskType(Enum): + BUILDER = "builder" + SERVICE = "service" + + +class DynamicTaskInfo(EBaseModel): + __admin_only_fields__: ClassVar = { + "dynamic_task_type", + } + dynamic_task_type: DynamicTaskType + + class Task(EBaseModel): __public_fields__: ClassVar = { "task_id", @@ -52,6 +66,9 @@ class Task(EBaseModel): "description", "flag", "hidden", + "points", + "solves", + "dynamic_task_info", } task_id: uuid.UUID = Field(default_factory=uuid.uuid4) @@ -72,6 +89,8 @@ class Task(EBaseModel): author: str + dynamic_task_info: DynamicTaskInfo | None = None + # @computed_field @property def color_category(self) -> str: @@ -147,6 +166,19 @@ def first_pwned_str(self) -> tuple[uuid.UUID, str]: def short_desc(self) -> str: return f"task_id={self.task_id} task_name={self.task_name} hidden={self.hidden} points={self.scoring.points}" + @computed_field + @property + def points(self) -> int: + return self.scoring.points + + @computed_field + @property + def solves(self) -> int: + return len(self.pwned_by) + + +_T = TypeVar("_T", bound=Task) + class TaskForm(EBaseModel): task_name: str @@ -155,3 +187,22 @@ class TaskForm(EBaseModel): description: str flag: FlagUnion author: str = "" + + dynamic_task_info: DynamicTaskInfo | None = None + + def to_task(self, cls: type[_T], author: User) -> _T: + str_author = self.author if self.author != "" else f"@{author.username}" + if not str_author.startswith("@"): + str_author = f"@{str_author}" + + task = cls( + task_name=self.task_name, + category=self.category, + scoring=self.scoring, + description=self.description, + description_html=Task.regenerate_md(self.description), + flag=self.flag, + author=str_author, + dynamic_task_info=self.dynamic_task_info, + ) + return task diff --git a/app/test/__init__.py b/app/test/__init__.py index 60d026d..b73fdb3 100644 --- a/app/test/__init__.py +++ b/app/test/__init__.py @@ -1,6 +1,7 @@ # ruff: noqa: S101, S106, ANN201, T201 # this is a __test file__ import typing +from contextlib import contextmanager import pytest from fastapi.testclient import TestClient @@ -127,5 +128,14 @@ def client(request): client.__exit__() +@contextmanager +def enable_debug() -> typing.Generator[None, typing.Any, None]: + settings.DEBUG = True + try: + yield + finally: + settings.DEBUG = False + + # from . import test_auth # noqa # from . import test_main # noqa diff --git a/app/test/test_auth.py b/app/test/test_auth.py index 28126f5..3da0589 100644 --- a/app/test/test_auth.py +++ b/app/test/test_auth.py @@ -3,7 +3,7 @@ from fastapi import status from .. import config, schema -from . import ClientEx, app +from . import ClientEx, app, enable_debug from . import client as client_cl client = client_cl @@ -12,7 +12,7 @@ def test_register(client: ClientEx): - resp = client.simple_register_raw(username="Rubikoid", password="123") + resp = client.simple_register_raw(username="Rubikoid", password="123456789") assert resp.status_code == status.HTTP_200_OK, resp.text assert resp.text == '"ok"', resp.text @@ -20,16 +20,17 @@ def test_register(client: ClientEx): def test_login(client: ClientEx): test_register(client) - resp = client.simple_login_raw(username="Rubikoid", password="123") + resp = client.simple_login_raw(username="Rubikoid", password="123456789") assert resp.status_code == status.HTTP_200_OK, resp.text assert resp.text == '"ok"', resp.text def test_admin(client: ClientEx): - # config.settings.DEBUG = True - test_login(client) - # config.settings.DEBUG = False + # need to enable debug here, because `Rubikoid-as-default-admin` is debug feature + with enable_debug(): + test_login(client) + resp = client.get(app.url_path_for("api_admin_users_me")) # print(resp.json()) assert resp.status_code == status.HTTP_200_OK, resp.text @@ -37,40 +38,47 @@ def test_admin(client: ClientEx): assert resp.json()["username"] == "Rubikoid", resp.json() +def test_not_admin_without_debug(client: ClientEx): + test_login(client) + resp = client.get(app.url_path_for("api_admin_users_me")) + assert resp.status_code == status.HTTP_403_FORBIDDEN, resp.text + + def test_admin_fail(client: ClientEx): - resp1 = client.simple_register_raw(username="Not_Rubikoid", password="123") - assert resp1.status_code == status.HTTP_200_OK, resp1.text - assert resp1.text == '"ok"', resp1.text + with enable_debug(): + resp1 = client.simple_register_raw(username="Not_Rubikoid", password="123456789") + assert resp1.status_code == status.HTTP_200_OK, resp1.text + assert resp1.text == '"ok"', resp1.text - resp2 = client.simple_login_raw(username="Not_Rubikoid", password="123") - assert resp2.status_code == status.HTTP_200_OK, resp2.text - assert resp2.text == '"ok"', resp2.text + resp2 = client.simple_login_raw(username="Not_Rubikoid", password="123456789") + assert resp2.status_code == status.HTTP_200_OK, resp2.text + assert resp2.text == '"ok"', resp2.text - resp3 = client.get(app.url_path_for("api_admin_users_me")) - assert resp3.status_code == status.HTTP_403_FORBIDDEN, resp3.text + resp3 = client.get(app.url_path_for("api_admin_users_me")) + assert resp3.status_code == status.HTTP_403_FORBIDDEN, resp3.text def test_not_existing_user(client: ClientEx): resp1 = client.post( app.url_path_for("api_auth_simple_login"), - json=LoginForm(username="Not_Existing_Account", password="123").model_dump(mode="json"), + json=LoginForm(username="Not_Existing_Account", password="123456789").model_dump(mode="json"), ) assert resp1.status_code == status.HTTP_401_UNAUTHORIZED, resp1.text def test_invalid_password(client: ClientEx): - resp1 = client.simple_register_raw(username="Not_Rubikoid", password="123") + resp1 = client.simple_register_raw(username="Not_Rubikoid", password="123456789") assert resp1.status_code == status.HTTP_200_OK, resp1.text assert resp1.text == '"ok"', resp1.text - resp2 = client.simple_login_raw(username="Not_Rubikoid", password="1234") + resp2 = client.simple_login_raw(username="Not_Rubikoid", password="1234567890") assert resp2.status_code == status.HTTP_401_UNAUTHORIZED, resp2.text def test_register_existing_user(client: ClientEx): - resp1 = client.simple_register_raw(username="Not_Rubikoid", password="123") + resp1 = client.simple_register_raw(username="Not_Rubikoid", password="123456789") assert resp1.status_code == status.HTTP_200_OK, resp1.text assert resp1.text == '"ok"', resp1.text - resp2 = client.simple_register_raw(username="Not_Rubikoid", password="1234") + resp2 = client.simple_register_raw(username="Not_Rubikoid", password="1234567890") assert resp2.status_code == status.HTTP_403_FORBIDDEN, resp2.text diff --git a/app/test/test_main.py b/app/test/test_main.py index d5c280f..f295f18 100644 --- a/app/test/test_main.py +++ b/app/test/test_main.py @@ -1,15 +1,9 @@ -import uuid - -import pytest - -from .. import schema -from . import TestClient, app +from . import TestClient from . import client as client_cl -from . import test_auth client = client_cl -def test_read_main(client: TestClient): +def test_read_main(client: TestClient) -> None: resp = client.get("/") assert resp.status_code == 200 diff --git a/app/test/test_tasks_api.py b/app/test/test_tasks_api.py index da4f8f1..6267b7e 100644 --- a/app/test/test_tasks_api.py +++ b/app/test/test_tasks_api.py @@ -66,7 +66,7 @@ def test_task_create(client: ClientEx): def test_task_solve(client: ClientEx): - client.simple_register_raw(username="Rubikoid", password="123") + test_auth.test_admin(client) tasks: dict[int, schema.Task] = {} # fake array ;) tasks[0] = client.create_task( @@ -97,7 +97,7 @@ def test_task_solve(client: ClientEx): task = tasks[i] assert not task.hidden, f"{task = }" - client.simple_register_raw(username="Rubikoid_user", password="123") + client.simple_register_raw(username="Rubikoid_user", password="123456789") resp1 = client.solve_task_raw("test_task") assert resp1.status_code == status.HTTP_200_OK, resp1.text diff --git a/app/utils/tg.py b/app/utils/tg.py index a5e4764..eb42778 100644 --- a/app/utils/tg.py +++ b/app/utils/tg.py @@ -1,6 +1,6 @@ from typing import cast -import requests +import httpx from .. import schema from ..config import settings @@ -9,11 +9,12 @@ logger = get_logger("api") -def to_tg(data: dict, path: str) -> requests.Response: +def to_tg(data: dict, path: str) -> httpx.Response | None: if not settings.BOT_TOKEN: - return + return None + url = f"https://api.telegram.org/bot{settings.BOT_TOKEN}/{path}" - ret = requests.post(url, data=data) + ret = httpx.post(url, data=data) logger.info(f"TG info={ret.text}") return ret diff --git a/app/view/__init__.py b/app/view/__init__.py index 3486f4f..1eaae9f 100644 --- a/app/view/__init__.py +++ b/app/view/__init__.py @@ -155,10 +155,9 @@ async def login_get(req: Request, resp: Response, user: auth.CURR_USER_SAFE): async def tasks_get_task( req: Request, resp: Response, - task_id: uuid.UUID, + task: api_tasks.CURRENT_TASK, user: auth.CURR_USER_SAFE, ): - task = await api_tasks.api_task_get(task_id, user) return await response_generator( req, "task.jhtml", diff --git a/app/view/admin/__init__.py b/app/view/admin/__init__.py index 912e317..fa63f9f 100644 --- a/app/view/admin/__init__.py +++ b/app/view/admin/__init__.py @@ -14,16 +14,21 @@ WebSocketException, status, ) +from fastapi.responses import HTMLResponse from fastapi.routing import APIRouter +from fastui import AnyComponent, FastUI, prebuilt_html +from fastui import components as c +from fastui.components.display import DisplayLookup, DisplayMode +from fastui.events import BackEvent, GoToEvent from ... import auth, config, schema -from ...api import api_tasks as api_tasks -from ...api import api_users as api_users -from ...api.admin import admin_checker +from ...api import api_tasks, api_users +from ...api.admin import CURR_ADMIN, admin_checker from ...api.admin import admin_tasks as api_admin_tasks from ...api.admin import admin_users as api_admin_users from ...utils.log_helper import get_logger from ...ws import ws_manager +from .ng import api_rotuer, base_router logger = get_logger("view") @@ -33,10 +38,11 @@ prefix="/admin", tags=["admin_view"], ) +router.include_router(base_router) @router.get("/") -async def admin_index(req: Request, resp: Response, user: schema.User = Depends(admin_checker)): +async def admin_index(req: Request, resp: Response, user: CURR_ADMIN): return await response_generator( req, "admin/index.jhtml", @@ -49,7 +55,7 @@ async def admin_index(req: Request, resp: Response, user: schema.User = Depends( @router.get("/tasks") -async def admin_tasks(req: Request, resp: Response, user: schema.User = Depends(admin_checker)): +async def admin_tasks(req: Request, resp: Response, user: CURR_ADMIN): tasks_list = await api_tasks.api_tasks_get(user) return await response_generator( req, @@ -66,7 +72,7 @@ async def admin_tasks(req: Request, resp: Response, user: schema.User = Depends( @router.get("/task/{task_id}") -async def admin_task_get(req: Request, resp: Response, task_id: uuid.UUID, user: schema.User = Depends(admin_checker)): +async def admin_task_get(req: Request, resp: Response, task_id: uuid.UUID, user: CURR_ADMIN): tasks_list = await api_tasks.api_tasks_get(user) selected_task = await api_tasks.api_task_get(task_id, user) return await response_generator( @@ -85,7 +91,7 @@ async def admin_task_get(req: Request, resp: Response, task_id: uuid.UUID, user: @router.get("/users") -async def admin_users(req: Request, resp: Response, user: schema.User = Depends(admin_checker)): +async def admin_users(req: Request, resp: Response, user: CURR_ADMIN): users_dict = await api_admin_users.api_admin_users_internal() return await response_generator( req, @@ -102,19 +108,18 @@ async def admin_users(req: Request, resp: Response, user: schema.User = Depends( @router.get("/user/{user_id}") -async def admin_user_get(req: Request, resp: Response, user_id: uuid.UUID, user: schema.User = Depends(admin_checker)): +async def admin_user_get(req: Request, resp: Response, admin: CURR_ADMIN, user: api_admin_users.CURR_USER): users_dict = await api_admin_users.api_admin_users_internal() - selected_user = await api_admin_users.api_admin_user_get_internal(user_id) return await response_generator( req, "admin/users_admin.jhtml", { "request": req, - "curr_user": user, + "curr_user": admin, "user_class": schema.User, # "user_form_class": schema.UserForm, "users_list": users_dict.values(), - "selected_user": selected_user, + "selected_user": user, }, ignore_admin=False, ) @@ -143,3 +148,16 @@ async def websocker_ep( await websocket.receive_text() except WebSocketDisconnect: ws_manager.disconnect(websocket) + + # return await response_generator( + # req, + # "admin/tasks_admin.jhtml", + # { + # "request": req, + # "curr_user": user, + # "task_class": schema.Task, + # "task_form_class": schema.TaskForm, + # "tasks_list": tasks_list, + # }, + # ignore_admin=False, + # ) diff --git a/app/view/admin/ng.py b/app/view/admin/ng.py new file mode 100644 index 0000000..8175fb2 --- /dev/null +++ b/app/view/admin/ng.py @@ -0,0 +1,172 @@ +import uuid + +from fastapi import ( + Cookie, + Depends, + FastAPI, + HTTPException, + Query, + Request, + Response, + WebSocket, + WebSocketDisconnect, + WebSocketException, + status, +) +from fastapi.responses import HTMLResponse +from fastapi.routing import APIRouter +from fastui import AnyComponent, FastUI, prebuilt_html +from fastui import components as c +from fastui.components.display import DisplayLookup, DisplayMode +from fastui.events import BackEvent, GoToEvent + +from ... import auth, config, schema +from ...api import api_tasks, api_users +from ...api.admin import CURR_ADMIN, admin_checker +from ...api.admin import admin_tasks as api_admin_tasks +from ...api.admin import admin_users as api_admin_users +from ...utils.log_helper import get_logger +from ...ws import ws_manager + +logger = get_logger("view") + +base_router = APIRouter( + prefix="/ng", + tags=["admin-ng"], +) + +api_rotuer = APIRouter( + prefix="/api/admin/ng", + tags=["admin-ng-api"], +) + + +def url_gen(req: Request, path: str) -> str: + return str(req.url_for("admin_ng_html_landing", path=path)) + + +def base_page(req: Request, *components: AnyComponent, title: str | None = None) -> list[AnyComponent]: + return [ + c.PageTitle(text=f"YATB Admin — {title if title else 'Root'}"), + c.Navbar( + title="YATB Admin", + title_event=GoToEvent(url=url_gen(req, "")), + start_links=[ + c.Link( + components=[c.Text(text="Tasks")], + on_click=GoToEvent(url=url_gen(req, "tasks")), + active="startswith:/tasks", + ), + c.Link( + components=[c.Text(text="Users")], + on_click=GoToEvent(url=url_gen(req, "users")), + active="startswith:/users", + ), + # c.Link( + # components=[c.Text(text="Components")], + # on_click=GoToEvent(url="/components"), + # active="startswith:/components", + # ), + ], + ), + c.Page( + components=[ + *((c.Heading(text=title),) if title else ()), + *components, + ], + ), + ] + + +@api_rotuer.get("", response_model=FastUI, response_model_exclude_none=True) +async def admin_ng_index(req: Request, admin: CURR_ADMIN) -> list[AnyComponent]: + return base_page(req, c.Text(text="...")) + + +@api_rotuer.get("/tasks", response_model=FastUI, response_model_exclude_none=True) +async def admin_ng_tasks(req: Request, admin: CURR_ADMIN) -> list[AnyComponent]: + tasks = await api_admin_tasks.api_admin_tasks(admin) + + return base_page( + req, + # c.ModelForm(submit_url="", model=schema.TaskForm), + c.Table( + data=[ + schema.Task.admin_model.model_validate(v.model_dump()) + for i, v in sorted(tasks.items(), key=lambda iv: iv[1].task_name) + ], + data_model=schema.Task.admin_model, + columns=[ + DisplayLookup(field="task_name", title="Name", on_click=GoToEvent(url=url_gen(req, "task/{task_id}"))), + DisplayLookup(field="category", title="Category"), + DisplayLookup(field="points", title="Points"), + DisplayLookup(field="solves", title="Solve Count"), + ], + ), + title="Tasks", + ) + + +@api_rotuer.get("/task/{task_id}", response_model=FastUI, response_model_exclude_none=True) +async def admin_ng_task(req: Request, admin: CURR_ADMIN, raw_task: api_admin_tasks.CURR_TASK) -> list[AnyComponent]: + sanitized_task = schema.Task.admin_model.model_validate(raw_task.model_dump()) + + return base_page( + req, + c.Heading(text=sanitized_task.task_name, level=2), + c.Link(components=[c.Text(text="Back")], on_click=BackEvent()), + c.Details( + data=sanitized_task, + # fields=[ + # DisplayLookup(field="task_id", title="ID"), + # ], + ), + title=f"Task - {sanitized_task.task_name}", + ) + + +@api_rotuer.get("/users", response_model=FastUI, response_model_exclude_none=True) +async def admin_ng_users(req: Request, admin: CURR_ADMIN) -> list[AnyComponent]: + users = await api_admin_users.api_admin_users(admin) + + return base_page( + req, + # c.ModelForm(submit_url="", model=schema.TaskForm), + c.Table( + data=[ + schema.User.admin_model.model_validate(v.model_dump()) + for i, v in sorted(users.items(), key=lambda iv: iv[1].username) + ], + data_model=schema.User.admin_model, + columns=[ + DisplayLookup(field="username", title="Name", on_click=GoToEvent(url=url_gen(req, "user/{user_id}"))), + DisplayLookup(field="is_admin", title="Admin"), + # DisplayLookup(field="points", title="Points"), + # DisplayLookup(field="solves", title="Solve Count"), + ], + ), + title="Users", + ) + + +@api_rotuer.get("/user/{user_id}", response_model=FastUI, response_model_exclude_none=True) +async def admin_ng_user(req: Request, admin: CURR_ADMIN, raw_user: api_admin_users.CURR_USER) -> list[AnyComponent]: + sanitized_user = schema.User.admin_model.model_validate(raw_user.model_dump()) + + return base_page( + req, + c.Heading(text=sanitized_user.username, level=2), + c.Link(components=[c.Text(text="Back")], on_click=BackEvent()), + c.Details( + data=sanitized_user, + # fields=[ + # DisplayLookup(field="task_id", title="ID"), + # ], + ), + title=f"User - {sanitized_user.username}", + ) + + +@base_router.get("/{path:path}") +async def admin_ng_html_landing() -> HTMLResponse: + return HTMLResponse(prebuilt_html(title="YATB admin..")) diff --git a/app/view/static/dynamic_tasks.js b/app/view/static/dynamic_tasks.js new file mode 100644 index 0000000..1e158ea --- /dev/null +++ b/app/view/static/dynamic_tasks.js @@ -0,0 +1,67 @@ +function load_all_dynamic_info() { + tasks = $(".dynamic_task_info_field").map((idx, info_field) => { + return info_field.dataset.id; + }).get(); + + req(api_list["api_dynamic_task_infos"], { data: { "tasks": tasks } }) + .then(get_json) + .then((data) => { return data; }, nok_toast_generator("Get dynamic task info")) + .then((data) => { + Object.entries(data.json.data).forEach(([task_id, info]) => { + set_status(task_id, info); + }); + }); +}; + +function set_status(task_id, info) { + $("#data-" + task_id).html(info); + updateMacy(); +} + +$(".dynamic_task_info").click(function (event) { + event.preventDefault(); + let task_id = this.dataset.id; + preq(api_list["api_dynamic_task_info"], { "task_id": task_id }, { method: 'GET' }) + .then(get_text) + .then((data) => { return data; }, nok_toast_generator("Get dynamic task info")) + .then((data) => { + set_status(task_id, data.text); + }); +}); + +$(".dynamic_task_start").click(function (event) { + event.preventDefault(); + let task_id = this.dataset.id; + + + set_status(task_id, "Task building in process") + + preq(api_list["api_dynamic_task_start"], { "task_id": task_id }, { method: 'GET' }) + .then(get_text) + .then(ok_toast_generator("Start dynamic task"), nok_toast_generator("Start dynamic task")) + .then((data) => { + set_status(task_id, data.text); + }); +}); + +$(".dynamic_task_stop").click(function (event) { + event.preventDefault(); + let task_id = this.dataset.id; + preq(api_list["api_dynamic_task_stop"], { "task_id": task_id }, { method: 'GET' }) + .then(get_text) + .then(ok_toast_generator("Stop dynamic task"), nok_toast_generator("Stop dynamic task")) + .then((data) => { + set_status(task_id, data.text); + }); +}); + +$(".dynamic_task_restart").click(function (event) { + event.preventDefault(); + let task_id = this.dataset.id; + preq(api_list["api_dynamic_task_restart"], { "task_id": task_id }, { method: 'GET' }) + .then(get_text) + .then(ok_toast_generator("Restart dynamic task"), nok_toast_generator("Restart dynamic task")) + .then((data) => { + set_status(task_id, data.text); + }); +}); diff --git a/app/view/static/style.css b/app/view/static/style.css index 9ba8ed4..a581b17 100644 --- a/app/view/static/style.css +++ b/app/view/static/style.css @@ -38,6 +38,16 @@ } } +@font-face { + font-family: "Spoof Medium"; + src: url(https://of.worldota.net/fonts/spoof/Spoof-Medium.woff2); +} + +@font-face { + font-family: "PT Root UI"; + src: url(https://of.worldota.net/fonts/ptrootui/pt-root-ui_vf.woff2); +} + html { position: relative; min-height: 100%; @@ -48,11 +58,23 @@ body { /* Margin top by header height */ margin-bottom: 60px; /* Margin bottom by footer height */ - background-color: #585858; + /* background-color: #acef81; */ + font-family: "PT Root UI"; + background-color: #f2f1f0; +} + +.marine-text { + color: rgb(13, 65, 210); + font-size: 16px; + font-weight: 500; } body>nav { - background-color: #404040; + background-color: white; +} + +.navbar-brand { + font-family: "Spoof Medium"; } .footer { @@ -63,30 +85,19 @@ body>nav { height: 60px; line-height: 60px; /* Vertically center the text there */ - color: #909090; - background-color: #404040; } -div.card>* a { - text-decoration: none; +.card { + background-color: #fff; } -div.card.solved:not(.secondary) { - background: repeating-linear-gradient(135deg, - #404040, - #404040 20px, - #585858 20px, - #585858 40px); - color: white; -} - -div.card.solved.secondary { - background: #404040; - color: white; +div.card>* a { + text-decoration: none; } -div.card.solved>div.card-header>*>* { - color: greenyellow; +div.card.solved { + background: #acef81; + color: #000; } div.card.solved>.card-body>.card-text>a { diff --git a/app/view/templates/admin/base.jhtml b/app/view/templates/admin/base.jhtml index bb4103d..643864a 100644 --- a/app/view/templates/admin/base.jhtml +++ b/app/view/templates/admin/base.jhtml @@ -7,6 +7,7 @@ {% set head_data.page_name = "YATB ADMIN" %} {% set head_data.pages = { + ("NG ADMIN",): url_for('admin_ng_html_landing', path=""), ("TASKS ADMIN",): url_for('admin_tasks'), ("USERS ADMIN",): url_for('admin_users'), ("Back to board",): url_for('index'), diff --git a/app/view/templates/base.jhtml b/app/view/templates/base.jhtml index e7eeb6d..f7197cb 100644 --- a/app/view/templates/base.jhtml +++ b/app/view/templates/base.jhtml @@ -16,7 +16,7 @@ - + @@ -33,53 +33,60 @@ {% set header_data.yatb_logo_text = CTF_NAME %} {% block header %} -