From 4893f54113328f9c9d247de106b9bcb7f7c96bbb Mon Sep 17 00:00:00 2001 From: Yan Shoshitaishvili Date: Thu, 25 Jan 2024 00:13:06 -0700 Subject: [PATCH] Bring back the hacker profiles (#303) * add a fast(er) global rankings function * restore a reasonably fast user profile * restore the hacker profile, and add badges * disable profiles for hidden users --- dojo_plugin/api/v1/scoreboard.py | 16 ++-------- dojo_plugin/pages/users.py | 25 ++++++++-------- dojo_plugin/utils/awards.py | 15 ++++++++++ dojo_plugin/utils/scores.py | 50 ++++++++++++++++++++++++++++++++ dojo_theme/templates/hacker.html | 46 +++++++++++++++++++---------- 5 files changed, 111 insertions(+), 41 deletions(-) create mode 100644 dojo_plugin/utils/scores.py diff --git a/dojo_plugin/api/v1/scoreboard.py b/dojo_plugin/api/v1/scoreboard.py index d44f3b2ee..97b6783dd 100644 --- a/dojo_plugin/api/v1/scoreboard.py +++ b/dojo_plugin/api/v1/scoreboard.py @@ -20,7 +20,7 @@ from ...models import Dojos, DojoChallenges, DojoUsers, DojoMembers, DojoAdmins, DojoStudents, DojoModules, DojoChallengeVisibilities, Belts, Emojis from ...utils import dojo_standings, user_dojos, first_bloods, daily_solve_counts from ...utils.dojo import dojo_route, dojo_accessible -from ...utils.awards import get_belts, belt_asset +from ...utils.awards import get_belts, belt_asset, get_viewable_emojis SCOREBOARD_CACHE_TIMEOUT_SECONDS = 60 * 60 * 2 # two hours make to cache all scoreboards scoreboard_namespace = Namespace("scoreboard") @@ -99,19 +99,7 @@ def get_scoreboard_page(model, duration=None, page=1, per_page=20): end_idx = start_idx + per_page pagination = Pagination(None, page, per_page, len(results), results[start_idx:end_idx]) user = get_current_user() - - viewable_dojos = { dojo.hex_dojo_id:dojo for dojo in Dojos.viewable(user=user) } - emojis = { } - for emoji in Emojis.query.order_by(Emojis.date).all(): - if emoji.category not in viewable_dojos: - continue - - emojis.setdefault(emoji.user.id, []).append({ - "text": emoji.description, - "emoji": emoji.name, - "count": 1, - "url": url_for("pwncollege_dojo.listing", dojo=viewable_dojos[emoji.category].reference_id) - }) + emojis = get_viewable_emojis(user) def standing(item): if not item: diff --git a/dojo_plugin/pages/users.py b/dojo_plugin/pages/users.py index 26cfb0229..631684512 100644 --- a/dojo_plugin/pages/users.py +++ b/dojo_plugin/pages/users.py @@ -4,7 +4,7 @@ import re from flask import Blueprint, Response, render_template, abort, url_for -from sqlalchemy.sql import and_ +from sqlalchemy.sql import and_, or_ from CTFd.utils.user import get_current_user from CTFd.utils.decorators import authed_only from CTFd.models import db, Users, Challenges, Solves @@ -12,24 +12,25 @@ from ..models import Dojos, DojoModules, DojoChallenges from ..config import DATA_DIR +from ..utils.scores import dojo_scores, module_scores +from ..utils.awards import get_belts, get_viewable_emojis users = Blueprint("pwncollege_users", __name__) def view_hacker(user): - current_user_dojos = set(Dojos.viewable(user=get_current_user())) - dojos = [dojo for dojo in Dojos.viewable(user=user) if dojo in current_user_dojos] + if user.hidden: + abort(404) - def ranking(model, user): - solves = db.func.count().label("solves") - rank = db.func.row_number().over(order_by=(solves.desc(), db.func.max(Solves.id))).label("rank") - rankings = model.solves().group_by(Solves.user_id).with_entities(rank, Solves.user_id).all() - user_rank = next((ranking.rank for ranking in rankings if ranking.user_id == user.id), None) - max_rank = len(rankings) - return user_rank, max_rank + dojos = Dojos.query.where(or_(Dojos.official, Dojos.data["type"] == "public")).all() - return render_template("hacker.html", dojos=dojos, user=user, ranking=ranking) + return render_template( + "hacker.html", + dojos=dojos, user=user, + dojo_scores=dojo_scores(), module_scores=module_scores(), + belts=get_belts(), badges=get_viewable_emojis(user) + ) @users.route("/hacker/") def view_other(user_id): @@ -86,4 +87,4 @@ def view_completion_report(hash): if not completion_report_path.exists(): abort(404) - return Response(completion_report_path.read_text(), mimetype="text") \ No newline at end of file + return Response(completion_report_path.read_text(), mimetype="text") diff --git a/dojo_plugin/utils/awards.py b/dojo_plugin/utils/awards.py index 39958c8e4..7412c8e9b 100644 --- a/dojo_plugin/utils/awards.py +++ b/dojo_plugin/utils/awards.py @@ -94,3 +94,18 @@ def update_awards(user): continue db.session.add(Emojis(user=user, name=emoji, description=f"Awarded for completing the {dojo_name} dojo.", category=dojo_id)) db.session.commit() + +def get_viewable_emojis(user): + viewable_dojos = { dojo.hex_dojo_id:dojo for dojo in Dojos.viewable(user=user) } + emojis = { } + for emoji in Emojis.query.order_by(Emojis.date).all(): + if emoji.category not in viewable_dojos: + continue + + emojis.setdefault(emoji.user.id, []).append({ + "text": emoji.description, + "emoji": emoji.name, + "count": 1, + "url": url_for("pwncollege_dojo.listing", dojo=viewable_dojos[emoji.category].reference_id) + }) + return emojis diff --git a/dojo_plugin/utils/scores.py b/dojo_plugin/utils/scores.py new file mode 100644 index 000000000..17cc77aef --- /dev/null +++ b/dojo_plugin/utils/scores.py @@ -0,0 +1,50 @@ +from sqlalchemy.sql import or_ +from CTFd.models import Solves, db +from CTFd.cache import cache +from ..models import Dojos, DojoChallenges + +@cache.memoize(timeout=1200) +def dojo_scores(): + solve_count = db.func.count(Solves.id).label("solve_count") + last_solve_id = db.func.max(Solves.id).label("last_solve_id") + dsc_query = db.session.query(Dojos.id, Solves.user_id, solve_count, last_solve_id).where( + Dojos.dojo_id == DojoChallenges.dojo_id, DojoChallenges.challenge_id == Solves.challenge_id, + or_(Dojos.data["type"] == "public", Dojos.official) + ).group_by(Dojos.id, Solves.user_id).order_by(Dojos.id, solve_count.desc(), last_solve_id) + + user_ranks = { } + user_solves = { } + dojo_ranks = { } + for dojo_id, user_id, solve_count, last_solve_id in dsc_query: + dojo_ranks.setdefault(dojo_id, [ ]).append(user_id) + user_ranks.setdefault(user_id, {})[dojo_id] = len(dojo_ranks[dojo_id]) + user_solves.setdefault(user_id, {})[dojo_id] = solve_count + + return { + "user_ranks": user_ranks, + "user_solves": user_solves, + "dojo_ranks": dojo_ranks + } + +@cache.memoize(timeout=1200) +def module_scores(): + solve_count = db.func.count(Solves.id).label("solve_count") + last_solve_id = db.func.max(Solves.id).label("last_solve_id") + dsc_query = db.session.query(Dojos.id, DojoChallenges.module_index, Solves.user_id, solve_count, last_solve_id).where( + Dojos.dojo_id == DojoChallenges.dojo_id, DojoChallenges.challenge_id == Solves.challenge_id, + or_(Dojos.data["type"] == "public", Dojos.official) + ).group_by(Dojos.id, DojoChallenges.module_index, Solves.user_id).order_by(Dojos.id, DojoChallenges.module_index, solve_count.desc(), last_solve_id) + + user_ranks = { } + user_solves = { } + module_ranks = { } + for dojo_id, module_idx, user_id, solve_count, last_solve_id in dsc_query: + module_ranks.setdefault(dojo_id, {}).setdefault(module_idx, []).append(user_id) + user_ranks.setdefault(user_id, {}).setdefault(dojo_id, {})[module_idx] = len(module_ranks[dojo_id][module_idx]) + user_solves.setdefault(user_id, {}).setdefault(dojo_id, {})[module_idx] = solve_count + + return { + "user_ranks": user_ranks, + "user_solves": user_solves, + "module_ranks": module_ranks + } diff --git a/dojo_theme/templates/hacker.html b/dojo_theme/templates/hacker.html index 7035f308f..62b2d8afd 100644 --- a/dojo_theme/templates/hacker.html +++ b/dojo_theme/templates/hacker.html @@ -4,7 +4,27 @@ {% block content %}
-

{{ user.name }}

+

{{ user.name }}

+

+ + {% if belts.users[user.id] %} + + + + {% else %} + + {% endif %} + +

+

+ {% for badge in badges[user.id] %} + + {{badge.emoji}} + + + {% endfor %} +

+ {% if user.affiliation %}

@@ -39,14 +59,15 @@

- {% for dojo in dojos if dojo.solves(user=user, ignore_visibility=True, ignore_admins=False).count() > 0 %} - {% set rank, max_rank = ranking(dojo, user) %} - {% set solves = dojo.solves(user=user, ignore_visibility=True, ignore_admins=False).all() | map(attribute="challenge_id") | list %} + {% for dojo in dojos if dojo_scores.user_ranks[user.id] and dojo_scores.user_ranks[user.id][dojo.id] %} + {% set rank = dojo_scores.user_ranks[user.id][dojo.id] %} + {% set max_rank = dojo_scores.dojo_ranks[dojo.id] | length %} + {% set solves = dojo_scores.user_solves[user.id][dojo.id] %}

{{ dojo.name }}

- {{ solves | length }} / {{ dojo.challenges | length }} + {{ solves }} / {{ dojo.challenges | length }} {{ rank or "-" }} / {{ max_rank or "-" }}

@@ -56,25 +77,20 @@

{% for module in dojo.modules %} - {% set solves = module.solves(user=user, ignore_visibility=True, ignore_admins=False).all() | map(attribute="challenge_id") | list %} - {% set rank, max_rank = ranking(module, user) %} + {% set solves = module_scores.user_solves[user.id][dojo.id][module.module_index] %} + {% set rank = module_scores.user_ranks[user.id][dojo.id][module.module_index] %} + {% set max_rank = module_scores.module_ranks[dojo.id][module.module_index] | length %} {% call(header) accordion_item("modules-{}".format(dojo.hex_dojo_id), loop.index) %} {% if header %}

{{ module.name }}

- {{ solves | length }} / {{ module.challenges | length }} + {{ solves }} / {{ module.challenges | length }} {{ rank or "-" }} / {{ max_rank or "-" }} {% else %} - {% for challenge in module.challenges %} - {% set challenge_status = "challenge-solved" if challenge.challenge_id in solves else "challenge-unsolved" %} -
- - {{ challenge.name }} -
- {% endfor %} + TODO {% endif %} {% endcall %} {% endfor %}