mirror of
https://github.com/aljazceru/CTFd.git
synced 2025-12-17 05:54:19 +01:00
Cache challenge data for faster loading of /api/v1/challenges (#2232)
* Improve response time of `/api/v1/challenges` and `/api/v1/challenges/[challenge_id]/solves` * Rewrite and remove _build_solves_query to make it cacheable * Closes #2209
This commit is contained in:
@@ -26,7 +26,13 @@ from CTFd.admin import statistics # noqa: F401
|
|||||||
from CTFd.admin import submissions # noqa: F401
|
from CTFd.admin import submissions # noqa: F401
|
||||||
from CTFd.admin import teams # noqa: F401
|
from CTFd.admin import teams # noqa: F401
|
||||||
from CTFd.admin import users # noqa: F401
|
from CTFd.admin import users # noqa: F401
|
||||||
from CTFd.cache import cache, clear_config, clear_pages, clear_standings
|
from CTFd.cache import (
|
||||||
|
cache,
|
||||||
|
clear_challenges,
|
||||||
|
clear_config,
|
||||||
|
clear_pages,
|
||||||
|
clear_standings,
|
||||||
|
)
|
||||||
from CTFd.models import (
|
from CTFd.models import (
|
||||||
Awards,
|
Awards,
|
||||||
Challenges,
|
Challenges,
|
||||||
@@ -238,6 +244,7 @@ def reset():
|
|||||||
|
|
||||||
clear_pages()
|
clear_pages()
|
||||||
clear_standings()
|
clear_standings()
|
||||||
|
clear_challenges()
|
||||||
clear_config()
|
clear_config()
|
||||||
|
|
||||||
if logout is True:
|
if logout is True:
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import datetime
|
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from flask import abort, render_template, request, url_for
|
from flask import abort, render_template, request, url_for
|
||||||
from flask_restx import Namespace, Resource
|
from flask_restx import Namespace, Resource
|
||||||
from sqlalchemy import func as sa_func
|
from sqlalchemy.sql import and_
|
||||||
from sqlalchemy.sql import and_, false, true
|
|
||||||
|
|
||||||
from CTFd.api.v1.helpers.request import validate_args
|
from CTFd.api.v1.helpers.request import validate_args
|
||||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||||
from CTFd.cache import clear_standings
|
from CTFd.cache import clear_challenges, clear_standings
|
||||||
from CTFd.constants import RawEnum
|
from CTFd.constants import RawEnum
|
||||||
from CTFd.models import ChallengeFiles as ChallengeFilesModel
|
from CTFd.models import ChallengeFiles as ChallengeFilesModel
|
||||||
from CTFd.models import Challenges
|
from CTFd.models import Challenges
|
||||||
@@ -22,12 +20,18 @@ from CTFd.schemas.hints import HintSchema
|
|||||||
from CTFd.schemas.tags import TagSchema
|
from CTFd.schemas.tags import TagSchema
|
||||||
from CTFd.utils import config, get_config
|
from CTFd.utils import config, get_config
|
||||||
from CTFd.utils import user as current_user
|
from CTFd.utils import user as current_user
|
||||||
|
from CTFd.utils.challenges import (
|
||||||
|
get_all_challenges,
|
||||||
|
get_solve_counts_for_challenges,
|
||||||
|
get_solve_ids_for_user_id,
|
||||||
|
get_solves_for_challenge_id,
|
||||||
|
)
|
||||||
from CTFd.utils.config.visibility import (
|
from CTFd.utils.config.visibility import (
|
||||||
accounts_visible,
|
accounts_visible,
|
||||||
challenges_visible,
|
challenges_visible,
|
||||||
scores_visible,
|
scores_visible,
|
||||||
)
|
)
|
||||||
from CTFd.utils.dates import ctf_ended, ctf_paused, ctftime, isoformat, unix_time_to_utc
|
from CTFd.utils.dates import ctf_ended, ctf_paused, ctftime
|
||||||
from CTFd.utils.decorators import (
|
from CTFd.utils.decorators import (
|
||||||
admins_only,
|
admins_only,
|
||||||
during_ctf_time_only,
|
during_ctf_time_only,
|
||||||
@@ -37,9 +41,7 @@ from CTFd.utils.decorators.visibility import (
|
|||||||
check_challenge_visibility,
|
check_challenge_visibility,
|
||||||
check_score_visibility,
|
check_score_visibility,
|
||||||
)
|
)
|
||||||
from CTFd.utils.helpers.models import build_model_filters
|
|
||||||
from CTFd.utils.logging import log
|
from CTFd.utils.logging import log
|
||||||
from CTFd.utils.modes import generate_account_url, get_model
|
|
||||||
from CTFd.utils.security.signing import serialize
|
from CTFd.utils.security.signing import serialize
|
||||||
from CTFd.utils.user import (
|
from CTFd.utils.user import (
|
||||||
authed,
|
authed,
|
||||||
@@ -77,60 +79,6 @@ challenges_namespace.schema_model(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_solves_query(extra_filters=(), admin_view=False):
|
|
||||||
"""Returns queries and data that that are used for showing an account's solves.
|
|
||||||
It returns a tuple of
|
|
||||||
- SQLAlchemy query with (challenge_id, solve_count_for_challenge_id)
|
|
||||||
- Current user's solved challenge IDs
|
|
||||||
"""
|
|
||||||
# This can return None (unauth) if visibility is set to public
|
|
||||||
user = get_current_user()
|
|
||||||
# We only set a condition for matching user solves if there is a user and
|
|
||||||
# they have an account ID (user mode or in a team in teams mode)
|
|
||||||
AccountModel = get_model()
|
|
||||||
if user is not None and user.account_id is not None:
|
|
||||||
user_solved_cond = Solves.account_id == user.account_id
|
|
||||||
else:
|
|
||||||
user_solved_cond = false()
|
|
||||||
# We have to filter solves to exclude any made after the current freeze
|
|
||||||
# time unless we're in an admin view as determined by the caller.
|
|
||||||
freeze = get_config("freeze")
|
|
||||||
if freeze and not admin_view:
|
|
||||||
freeze_cond = Solves.date < unix_time_to_utc(freeze)
|
|
||||||
else:
|
|
||||||
freeze_cond = true()
|
|
||||||
# Finally, we never count solves made by hidden or banned users/teams, even
|
|
||||||
# if we are an admin. This is to match the challenge detail API.
|
|
||||||
exclude_solves_cond = and_(
|
|
||||||
AccountModel.banned == false(), AccountModel.hidden == false(),
|
|
||||||
)
|
|
||||||
# This query counts the number of solves per challenge, as well as the sum
|
|
||||||
# of correct solves made by the current user per the condition above (which
|
|
||||||
# should probably only be 0 or 1!)
|
|
||||||
solves_q = (
|
|
||||||
db.session.query(Solves.challenge_id, sa_func.count(Solves.challenge_id),)
|
|
||||||
.join(AccountModel)
|
|
||||||
.filter(*extra_filters, freeze_cond, exclude_solves_cond)
|
|
||||||
.group_by(Solves.challenge_id)
|
|
||||||
)
|
|
||||||
# Also gather the user's solve items which can be different from above query
|
|
||||||
# For example, even if we are a hidden user, we should see that we have solved a challenge
|
|
||||||
# however as a hidden user we are not included in the count of the above query
|
|
||||||
if admin_view:
|
|
||||||
# If we're an admin we should show all challenges as solved to break through any requirements
|
|
||||||
challenges = Challenges.query.all()
|
|
||||||
solve_ids = {challenge.id for challenge in challenges}
|
|
||||||
else:
|
|
||||||
# If not an admin we calculate solves as normal
|
|
||||||
solve_ids = (
|
|
||||||
Solves.query.with_entities(Solves.challenge_id)
|
|
||||||
.filter(user_solved_cond)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
solve_ids = {value for value, in solve_ids}
|
|
||||||
return solves_q, solve_ids
|
|
||||||
|
|
||||||
|
|
||||||
@challenges_namespace.route("")
|
@challenges_namespace.route("")
|
||||||
class ChallengeList(Resource):
|
class ChallengeList(Resource):
|
||||||
@check_challenge_visibility
|
@check_challenge_visibility
|
||||||
@@ -185,23 +133,22 @@ class ChallengeList(Resource):
|
|||||||
# Build filtering queries
|
# Build filtering queries
|
||||||
q = query_args.pop("q", None)
|
q = query_args.pop("q", None)
|
||||||
field = str(query_args.pop("field", None))
|
field = str(query_args.pop("field", None))
|
||||||
filters = build_model_filters(model=Challenges, query=q, field=field)
|
|
||||||
|
|
||||||
# Admins get a shortcut to see all challenges despite pre-requisites
|
# Admins get a shortcut to see all challenges despite pre-requisites
|
||||||
admin_view = is_admin() and request.args.get("view") == "admin"
|
admin_view = is_admin() and request.args.get("view") == "admin"
|
||||||
|
|
||||||
solve_counts = {}
|
# Get a cached mapping of challenge_id to solve_count
|
||||||
# Build a query for to show challenge solve information. We only
|
solve_counts = get_solve_counts_for_challenges(admin=admin_view)
|
||||||
# give an admin view if the request argument has been provided.
|
|
||||||
#
|
# Get list of solve_ids for current user
|
||||||
# NOTE: This is different behaviour to the challenge detail
|
if authed():
|
||||||
# endpoint which only needs the current user to be an admin rather
|
user = get_current_user()
|
||||||
# than also also having to provide `view=admin` as a query arg.
|
user_solves = get_solve_ids_for_user_id(user_id=user.id)
|
||||||
solves_q, user_solves = _build_solves_query(admin_view=admin_view)
|
else:
|
||||||
|
user_solves = set()
|
||||||
|
|
||||||
# Aggregate the query results into the hashes defined at the top of
|
# Aggregate the query results into the hashes defined at the top of
|
||||||
# this block for later use
|
# this block for later use
|
||||||
for chal_id, solve_count in solves_q:
|
|
||||||
solve_counts[chal_id] = solve_count
|
|
||||||
if scores_visible() and accounts_visible():
|
if scores_visible() and accounts_visible():
|
||||||
solve_count_dfl = 0
|
solve_count_dfl = 0
|
||||||
else:
|
else:
|
||||||
@@ -211,18 +158,7 @@ class ChallengeList(Resource):
|
|||||||
# `None` for the solve count if visiblity checks fail
|
# `None` for the solve count if visiblity checks fail
|
||||||
solve_count_dfl = None
|
solve_count_dfl = None
|
||||||
|
|
||||||
# Build the query for the challenges which may be listed
|
chal_q = get_all_challenges(admin=admin_view, field=field, q=q, **query_args)
|
||||||
chal_q = Challenges.query
|
|
||||||
# Admins can see hidden and locked challenges in the admin view
|
|
||||||
if admin_view is False:
|
|
||||||
chal_q = chal_q.filter(
|
|
||||||
and_(Challenges.state != "hidden", Challenges.state != "locked")
|
|
||||||
)
|
|
||||||
chal_q = (
|
|
||||||
chal_q.filter_by(**query_args)
|
|
||||||
.filter(*filters)
|
|
||||||
.order_by(Challenges.value, Challenges.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Iterate through the list of challenges, adding to the object which
|
# Iterate through the list of challenges, adding to the object which
|
||||||
# will be JSONified back to the client
|
# will be JSONified back to the client
|
||||||
@@ -308,6 +244,9 @@ class ChallengeList(Resource):
|
|||||||
challenge_class = get_chal_class(challenge_type)
|
challenge_class = get_chal_class(challenge_type)
|
||||||
challenge = challenge_class.create(request)
|
challenge = challenge_class.create(request)
|
||||||
response = challenge_class.read(challenge)
|
response = challenge_class.read(challenge)
|
||||||
|
|
||||||
|
clear_challenges()
|
||||||
|
|
||||||
return {"success": True, "data": response}
|
return {"success": True, "data": response}
|
||||||
|
|
||||||
|
|
||||||
@@ -453,13 +392,17 @@ class Challenge(Resource):
|
|||||||
|
|
||||||
response = chal_class.read(challenge=chal)
|
response = chal_class.read(challenge=chal)
|
||||||
|
|
||||||
solves_q, user_solves = _build_solves_query(
|
# Get list of solve_ids for current user
|
||||||
extra_filters=(Solves.challenge_id == chal.id,)
|
if authed():
|
||||||
)
|
user = get_current_user()
|
||||||
# If there are no solves for this challenge ID then we have 0 rows
|
user_solves = get_solve_ids_for_user_id(user_id=user.id)
|
||||||
maybe_row = solves_q.first()
|
else:
|
||||||
if maybe_row:
|
user_solves = []
|
||||||
challenge_id, solve_count = maybe_row
|
|
||||||
|
solves_count = get_solve_counts_for_challenges(challenge_id=chal.id)
|
||||||
|
if solves_count:
|
||||||
|
challenge_id = chal.id
|
||||||
|
solve_count = solves_count.get(chal.id)
|
||||||
solved_by_user = challenge_id in user_solves
|
solved_by_user = challenge_id in user_solves
|
||||||
else:
|
else:
|
||||||
solve_count, solved_by_user = 0, False
|
solve_count, solved_by_user = 0, False
|
||||||
@@ -522,6 +465,10 @@ class Challenge(Resource):
|
|||||||
challenge_class = get_chal_class(challenge.type)
|
challenge_class = get_chal_class(challenge.type)
|
||||||
challenge = challenge_class.update(challenge, request)
|
challenge = challenge_class.update(challenge, request)
|
||||||
response = challenge_class.read(challenge)
|
response = challenge_class.read(challenge)
|
||||||
|
|
||||||
|
clear_standings()
|
||||||
|
clear_challenges()
|
||||||
|
|
||||||
return {"success": True, "data": response}
|
return {"success": True, "data": response}
|
||||||
|
|
||||||
@admins_only
|
@admins_only
|
||||||
@@ -534,6 +481,9 @@ class Challenge(Resource):
|
|||||||
chal_class = get_chal_class(challenge.type)
|
chal_class = get_chal_class(challenge.type)
|
||||||
chal_class.delete(challenge)
|
chal_class.delete(challenge)
|
||||||
|
|
||||||
|
clear_standings()
|
||||||
|
clear_challenges()
|
||||||
|
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -675,6 +625,7 @@ class ChallengeAttempt(Resource):
|
|||||||
user=user, team=team, challenge=challenge, request=request
|
user=user, team=team, challenge=challenge, request=request
|
||||||
)
|
)
|
||||||
clear_standings()
|
clear_standings()
|
||||||
|
clear_challenges()
|
||||||
|
|
||||||
log(
|
log(
|
||||||
"submissions",
|
"submissions",
|
||||||
@@ -694,6 +645,7 @@ class ChallengeAttempt(Resource):
|
|||||||
user=user, team=team, challenge=challenge, request=request
|
user=user, team=team, challenge=challenge, request=request
|
||||||
)
|
)
|
||||||
clear_standings()
|
clear_standings()
|
||||||
|
clear_challenges()
|
||||||
|
|
||||||
log(
|
log(
|
||||||
"submissions",
|
"submissions",
|
||||||
@@ -762,41 +714,15 @@ class ChallengeSolves(Resource):
|
|||||||
if challenge.state == "hidden" and is_admin() is False:
|
if challenge.state == "hidden" and is_admin() is False:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
Model = get_model()
|
|
||||||
|
|
||||||
# Note that we specifically query for the Solves.account.name
|
|
||||||
# attribute here because it is faster than having SQLAlchemy
|
|
||||||
# query for the attribute directly and it's unknown what the
|
|
||||||
# affects of changing the relationship lazy attribute would be
|
|
||||||
solves = (
|
|
||||||
Solves.query.add_columns(Model.name.label("account_name"))
|
|
||||||
.join(Model, Solves.account_id == Model.id)
|
|
||||||
.filter(
|
|
||||||
Solves.challenge_id == challenge_id,
|
|
||||||
Model.banned == False,
|
|
||||||
Model.hidden == False,
|
|
||||||
)
|
|
||||||
.order_by(Solves.date.asc())
|
|
||||||
)
|
|
||||||
|
|
||||||
freeze = get_config("freeze")
|
freeze = get_config("freeze")
|
||||||
if freeze:
|
if freeze:
|
||||||
preview = request.args.get("preview")
|
preview = request.args.get("preview")
|
||||||
if (is_admin() is False) or (is_admin() is True and preview):
|
if (is_admin() is False) or (is_admin() is True and preview):
|
||||||
dt = datetime.datetime.utcfromtimestamp(freeze)
|
freeze = True
|
||||||
solves = solves.filter(Solves.date < dt)
|
elif is_admin() is True:
|
||||||
|
freeze = False
|
||||||
|
|
||||||
for solve in solves:
|
response = get_solves_for_challenge_id(challenge_id=challenge_id, freeze=freeze)
|
||||||
# Seperate out the account name and the Solve object from the SQLAlchemy tuple
|
|
||||||
solve, account_name = solve
|
|
||||||
response.append(
|
|
||||||
{
|
|
||||||
"account_id": solve.account_id,
|
|
||||||
"name": account_name,
|
|
||||||
"date": isoformat(solve.date),
|
|
||||||
"account_url": generate_account_url(account_id=solve.account_id),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"success": True, "data": response}
|
return {"success": True, "data": response}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from flask_restx import Namespace, Resource
|
|||||||
from CTFd.api.v1.helpers.request import validate_args
|
from CTFd.api.v1.helpers.request import validate_args
|
||||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||||
from CTFd.cache import clear_config, clear_standings
|
from CTFd.cache import clear_challenges, clear_config, clear_standings
|
||||||
from CTFd.constants import RawEnum
|
from CTFd.constants import RawEnum
|
||||||
from CTFd.models import Configs, Fields, db
|
from CTFd.models import Configs, Fields, db
|
||||||
from CTFd.schemas.config import ConfigSchema
|
from CTFd.schemas.config import ConfigSchema
|
||||||
@@ -99,6 +99,7 @@ class ConfigList(Resource):
|
|||||||
|
|
||||||
clear_config()
|
clear_config()
|
||||||
clear_standings()
|
clear_standings()
|
||||||
|
clear_challenges()
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@@ -119,6 +120,7 @@ class ConfigList(Resource):
|
|||||||
|
|
||||||
clear_config()
|
clear_config()
|
||||||
clear_standings()
|
clear_standings()
|
||||||
|
clear_challenges()
|
||||||
|
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
@@ -175,6 +177,7 @@ class Config(Resource):
|
|||||||
|
|
||||||
clear_config()
|
clear_config()
|
||||||
clear_standings()
|
clear_standings()
|
||||||
|
clear_challenges()
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@@ -192,6 +195,7 @@ class Config(Resource):
|
|||||||
|
|
||||||
clear_config()
|
clear_config()
|
||||||
clear_standings()
|
clear_standings()
|
||||||
|
clear_challenges()
|
||||||
|
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from CTFd.api.v1.schemas import (
|
|||||||
APIDetailedSuccessResponse,
|
APIDetailedSuccessResponse,
|
||||||
PaginatedAPIListSuccessResponse,
|
PaginatedAPIListSuccessResponse,
|
||||||
)
|
)
|
||||||
from CTFd.cache import clear_standings
|
from CTFd.cache import clear_challenges, clear_standings
|
||||||
from CTFd.constants import RawEnum
|
from CTFd.constants import RawEnum
|
||||||
from CTFd.models import Submissions, db
|
from CTFd.models import Submissions, db
|
||||||
from CTFd.schemas.submissions import SubmissionSchema
|
from CTFd.schemas.submissions import SubmissionSchema
|
||||||
@@ -141,6 +141,8 @@ class SubmissionsList(Resource):
|
|||||||
|
|
||||||
# Delete standings cache
|
# Delete standings cache
|
||||||
clear_standings()
|
clear_standings()
|
||||||
|
# Delete challenges cache
|
||||||
|
clear_challenges()
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@@ -188,5 +190,6 @@ class Submission(Resource):
|
|||||||
|
|
||||||
# Delete standings cache
|
# Delete standings cache
|
||||||
clear_standings()
|
clear_standings()
|
||||||
|
clear_challenges()
|
||||||
|
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ from CTFd.api.v1.schemas import (
|
|||||||
APIDetailedSuccessResponse,
|
APIDetailedSuccessResponse,
|
||||||
PaginatedAPIListSuccessResponse,
|
PaginatedAPIListSuccessResponse,
|
||||||
)
|
)
|
||||||
from CTFd.cache import clear_standings, clear_team_session, clear_user_session
|
from CTFd.cache import (
|
||||||
|
clear_challenges,
|
||||||
|
clear_standings,
|
||||||
|
clear_team_session,
|
||||||
|
clear_user_session,
|
||||||
|
)
|
||||||
from CTFd.constants import RawEnum
|
from CTFd.constants import RawEnum
|
||||||
from CTFd.models import Awards, Submissions, Teams, Unlocks, Users, db
|
from CTFd.models import Awards, Submissions, Teams, Unlocks, Users, db
|
||||||
from CTFd.schemas.awards import AwardSchema
|
from CTFd.schemas.awards import AwardSchema
|
||||||
@@ -155,6 +160,7 @@ class TeamList(Resource):
|
|||||||
db.session.close()
|
db.session.close()
|
||||||
|
|
||||||
clear_standings()
|
clear_standings()
|
||||||
|
clear_challenges()
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@@ -220,6 +226,7 @@ class TeamPublic(Resource):
|
|||||||
|
|
||||||
clear_team_session(team_id=team.id)
|
clear_team_session(team_id=team.id)
|
||||||
clear_standings()
|
clear_standings()
|
||||||
|
clear_challenges()
|
||||||
|
|
||||||
db.session.close()
|
db.session.close()
|
||||||
|
|
||||||
@@ -243,6 +250,7 @@ class TeamPublic(Resource):
|
|||||||
|
|
||||||
clear_team_session(team_id=team_id)
|
clear_team_session(team_id=team_id)
|
||||||
clear_standings()
|
clear_standings()
|
||||||
|
clear_challenges()
|
||||||
|
|
||||||
db.session.close()
|
db.session.close()
|
||||||
|
|
||||||
@@ -375,6 +383,7 @@ class TeamPrivate(Resource):
|
|||||||
|
|
||||||
clear_team_session(team_id=team.id)
|
clear_team_session(team_id=team.id)
|
||||||
clear_standings()
|
clear_standings()
|
||||||
|
clear_challenges()
|
||||||
|
|
||||||
db.session.close()
|
db.session.close()
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from CTFd.api.v1.schemas import (
|
|||||||
APIDetailedSuccessResponse,
|
APIDetailedSuccessResponse,
|
||||||
PaginatedAPIListSuccessResponse,
|
PaginatedAPIListSuccessResponse,
|
||||||
)
|
)
|
||||||
from CTFd.cache import clear_standings, clear_user_session
|
from CTFd.cache import clear_challenges, clear_standings, clear_user_session
|
||||||
from CTFd.constants import RawEnum
|
from CTFd.constants import RawEnum
|
||||||
from CTFd.models import (
|
from CTFd.models import (
|
||||||
Awards,
|
Awards,
|
||||||
@@ -165,6 +165,7 @@ class UserList(Resource):
|
|||||||
user_created_notification(addr=email, name=name, password=password)
|
user_created_notification(addr=email, name=name, password=password)
|
||||||
|
|
||||||
clear_standings()
|
clear_standings()
|
||||||
|
clear_challenges()
|
||||||
|
|
||||||
response = schema.dump(response.data)
|
response = schema.dump(response.data)
|
||||||
|
|
||||||
@@ -242,6 +243,7 @@ class UserPublic(Resource):
|
|||||||
|
|
||||||
clear_user_session(user_id=user_id)
|
clear_user_session(user_id=user_id)
|
||||||
clear_standings()
|
clear_standings()
|
||||||
|
clear_challenges()
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@@ -270,6 +272,7 @@ class UserPublic(Resource):
|
|||||||
|
|
||||||
clear_user_session(user_id=user_id)
|
clear_user_session(user_id=user_id)
|
||||||
clear_standings()
|
clear_standings()
|
||||||
|
clear_challenges()
|
||||||
|
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
@@ -322,6 +325,7 @@ class UserPrivate(Resource):
|
|||||||
db.session.close()
|
db.session.close()
|
||||||
|
|
||||||
clear_standings()
|
clear_standings()
|
||||||
|
clear_challenges()
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
|
|||||||
12
CTFd/cache/__init__.py
vendored
12
CTFd/cache/__init__.py
vendored
@@ -98,6 +98,18 @@ def clear_standings():
|
|||||||
cache.delete(make_template_fragment_key(CacheKeys.PUBLIC_SCOREBOARD_TABLE))
|
cache.delete(make_template_fragment_key(CacheKeys.PUBLIC_SCOREBOARD_TABLE))
|
||||||
|
|
||||||
|
|
||||||
|
def clear_challenges():
|
||||||
|
from CTFd.utils.challenges import get_all_challenges
|
||||||
|
from CTFd.utils.challenges import get_solves_for_challenge_id
|
||||||
|
from CTFd.utils.challenges import get_solve_ids_for_user_id
|
||||||
|
from CTFd.utils.challenges import get_solve_counts_for_challenges
|
||||||
|
|
||||||
|
cache.delete_memoized(get_all_challenges)
|
||||||
|
cache.delete_memoized(get_solves_for_challenge_id)
|
||||||
|
cache.delete_memoized(get_solve_ids_for_user_id)
|
||||||
|
cache.delete_memoized(get_solve_counts_for_challenges)
|
||||||
|
|
||||||
|
|
||||||
def clear_pages():
|
def clear_pages():
|
||||||
from CTFd.utils.config.pages import get_page, get_pages
|
from CTFd.utils.config.pages import get_page, get_pages
|
||||||
|
|
||||||
|
|||||||
126
CTFd/utils/challenges/__init__.py
Normal file
126
CTFd/utils/challenges/__init__.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import datetime
|
||||||
|
from collections import namedtuple
|
||||||
|
|
||||||
|
from sqlalchemy import func as sa_func
|
||||||
|
from sqlalchemy.sql import and_, false, true
|
||||||
|
|
||||||
|
from CTFd.cache import cache
|
||||||
|
from CTFd.models import Challenges, Solves, Users, db
|
||||||
|
from CTFd.schemas.tags import TagSchema
|
||||||
|
from CTFd.utils import get_config
|
||||||
|
from CTFd.utils.dates import isoformat, unix_time_to_utc
|
||||||
|
from CTFd.utils.helpers.models import build_model_filters
|
||||||
|
from CTFd.utils.modes import generate_account_url, get_model
|
||||||
|
|
||||||
|
Challenge = namedtuple(
|
||||||
|
"Challenge", ["id", "type", "name", "value", "category", "tags", "requirements"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@cache.memoize(timeout=60)
|
||||||
|
def get_all_challenges(admin=False, field=None, q=None, **query_args):
|
||||||
|
filters = build_model_filters(model=Challenges, query=q, field=field)
|
||||||
|
chal_q = Challenges.query
|
||||||
|
# Admins can see hidden and locked challenges in the admin view
|
||||||
|
if admin is False:
|
||||||
|
chal_q = chal_q.filter(
|
||||||
|
and_(Challenges.state != "hidden", Challenges.state != "locked")
|
||||||
|
)
|
||||||
|
chal_q = (
|
||||||
|
chal_q.filter_by(**query_args)
|
||||||
|
.filter(*filters)
|
||||||
|
.order_by(Challenges.value, Challenges.id)
|
||||||
|
)
|
||||||
|
tag_schema = TagSchema(view="user", many=True)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for c in chal_q:
|
||||||
|
ct = Challenge(
|
||||||
|
id=c.id,
|
||||||
|
type=c.type,
|
||||||
|
name=c.name,
|
||||||
|
value=c.value,
|
||||||
|
category=c.category,
|
||||||
|
requirements=c.requirements,
|
||||||
|
tags=tag_schema.dump(c.tags).data,
|
||||||
|
)
|
||||||
|
results.append(ct)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@cache.memoize(timeout=60)
|
||||||
|
def get_solves_for_challenge_id(challenge_id, freeze=False):
|
||||||
|
Model = get_model()
|
||||||
|
# Note that we specifically query for the Solves.account.name
|
||||||
|
# attribute here because it is faster than having SQLAlchemy
|
||||||
|
# query for the attribute directly and it's unknown what the
|
||||||
|
# affects of changing the relationship lazy attribute would be
|
||||||
|
solves = (
|
||||||
|
Solves.query.add_columns(Model.name.label("account_name"))
|
||||||
|
.join(Model, Solves.account_id == Model.id)
|
||||||
|
.filter(
|
||||||
|
Solves.challenge_id == challenge_id,
|
||||||
|
Model.banned == False,
|
||||||
|
Model.hidden == False,
|
||||||
|
)
|
||||||
|
.order_by(Solves.date.asc())
|
||||||
|
)
|
||||||
|
if freeze:
|
||||||
|
freeze_time = get_config("freeze")
|
||||||
|
if freeze_time:
|
||||||
|
dt = datetime.datetime.utcfromtimestamp(freeze_time)
|
||||||
|
solves = solves.filter(Solves.date < dt)
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for solve in solves:
|
||||||
|
# Seperate out the account name and the Solve object from the SQLAlchemy tuple
|
||||||
|
solve, account_name = solve
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"account_id": solve.account_id,
|
||||||
|
"name": account_name,
|
||||||
|
"date": isoformat(solve.date),
|
||||||
|
"account_url": generate_account_url(account_id=solve.account_id),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@cache.memoize(timeout=60)
|
||||||
|
def get_solve_ids_for_user_id(user_id):
|
||||||
|
user = Users.query.filter_by(id=user_id).first()
|
||||||
|
solve_ids = (
|
||||||
|
Solves.query.with_entities(Solves.challenge_id)
|
||||||
|
.filter(Solves.account_id == user.account_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
solve_ids = {value for value, in solve_ids}
|
||||||
|
return solve_ids
|
||||||
|
|
||||||
|
|
||||||
|
@cache.memoize(timeout=60)
|
||||||
|
def get_solve_counts_for_challenges(challenge_id=None, admin=False):
|
||||||
|
if challenge_id is None:
|
||||||
|
challenge_id_filter = ()
|
||||||
|
else:
|
||||||
|
challenge_id_filter = (Solves.challenge_id == challenge_id,)
|
||||||
|
AccountModel = get_model()
|
||||||
|
freeze = get_config("freeze")
|
||||||
|
if freeze and not admin:
|
||||||
|
freeze_cond = Solves.date < unix_time_to_utc(freeze)
|
||||||
|
else:
|
||||||
|
freeze_cond = true()
|
||||||
|
exclude_solves_cond = and_(
|
||||||
|
AccountModel.banned == false(), AccountModel.hidden == false(),
|
||||||
|
)
|
||||||
|
solves_q = (
|
||||||
|
db.session.query(Solves.challenge_id, sa_func.count(Solves.challenge_id),)
|
||||||
|
.join(AccountModel)
|
||||||
|
.filter(*challenge_id_filter, freeze_cond, exclude_solves_cond)
|
||||||
|
.group_by(Solves.challenge_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
solve_counts = {}
|
||||||
|
for chal_id, solve_count in solves_q:
|
||||||
|
solve_counts[chal_id] = solve_count
|
||||||
|
return solve_counts
|
||||||
@@ -7,7 +7,7 @@ import random
|
|||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
from CTFd import create_app
|
from CTFd import create_app
|
||||||
from CTFd.cache import clear_config, clear_standings, clear_pages
|
from CTFd.cache import clear_challenges, clear_config, clear_standings, clear_pages
|
||||||
from CTFd.models import (
|
from CTFd.models import (
|
||||||
Users,
|
Users,
|
||||||
Teams,
|
Teams,
|
||||||
@@ -352,4 +352,5 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
clear_config()
|
clear_config()
|
||||||
clear_standings()
|
clear_standings()
|
||||||
|
clear_challenges()
|
||||||
clear_pages()
|
clear_pages()
|
||||||
|
|||||||
@@ -394,8 +394,9 @@ def test_api_challenges_get_solve_count_banned_user():
|
|||||||
assert chal_data["solves"] == 1
|
assert chal_data["solves"] == 1
|
||||||
|
|
||||||
# Ban the user
|
# Ban the user
|
||||||
Users.query.get(2).banned = True
|
with login_as_user(app, name="admin") as client:
|
||||||
app.db.session.commit()
|
r = client.patch("/api/v1/users/2", json={"banned": True})
|
||||||
|
assert Users.query.get(2).banned == True
|
||||||
|
|
||||||
with app.test_client() as client:
|
with app.test_client() as client:
|
||||||
# Confirm solve count is `0` despite the banned user having solved
|
# Confirm solve count is `0` despite the banned user having solved
|
||||||
@@ -823,8 +824,9 @@ def test_api_challenge_get_solve_count_banned_user():
|
|||||||
assert chal_data["solves"] == 1
|
assert chal_data["solves"] == 1
|
||||||
|
|
||||||
# Ban the user
|
# Ban the user
|
||||||
Users.query.get(2).banned = True
|
with login_as_user(app, name="admin") as client:
|
||||||
app.db.session.commit()
|
r = client.patch("/api/v1/users/2", json={"banned": True})
|
||||||
|
assert Users.query.get(2).banned == True
|
||||||
|
|
||||||
# Confirm solve count is `0` despite the banned user having solved
|
# Confirm solve count is `0` despite the banned user having solved
|
||||||
with app.test_client() as client:
|
with app.test_client() as client:
|
||||||
|
|||||||
128
tests/cache/test_challenges.py
vendored
Normal file
128
tests/cache/test_challenges.py
vendored
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from CTFd.models import Users
|
||||||
|
from tests.helpers import (
|
||||||
|
create_ctfd,
|
||||||
|
destroy_ctfd,
|
||||||
|
login_as_user,
|
||||||
|
register_user,
|
||||||
|
simulate_user_activity,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_adding_challenge_clears_cache():
|
||||||
|
"""
|
||||||
|
Test that when we add a challenge, it appears in our challenge list
|
||||||
|
"""
|
||||||
|
app = create_ctfd()
|
||||||
|
with app.app_context():
|
||||||
|
register_user(app)
|
||||||
|
|
||||||
|
with login_as_user(app) as client, login_as_user(
|
||||||
|
app, name="admin", password="password"
|
||||||
|
) as admin:
|
||||||
|
req = client.get("/api/v1/challenges")
|
||||||
|
data = req.get_json()
|
||||||
|
assert data["data"] == []
|
||||||
|
|
||||||
|
challenge_data = {
|
||||||
|
"name": "name",
|
||||||
|
"category": "category",
|
||||||
|
"description": "description",
|
||||||
|
"value": 100,
|
||||||
|
"state": "visible",
|
||||||
|
"type": "standard",
|
||||||
|
}
|
||||||
|
|
||||||
|
r = admin.post("/api/v1/challenges", json=challenge_data)
|
||||||
|
assert r.get_json().get("data")["id"] == 1
|
||||||
|
|
||||||
|
req = client.get("/api/v1/challenges")
|
||||||
|
data = req.get_json()
|
||||||
|
assert data["data"] != []
|
||||||
|
destroy_ctfd(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleting_challenge_clears_cache_solves():
|
||||||
|
"""
|
||||||
|
Test that deleting a challenge clears the cached solves for the challenge
|
||||||
|
"""
|
||||||
|
app = create_ctfd()
|
||||||
|
with app.app_context():
|
||||||
|
register_user(app)
|
||||||
|
user = Users.query.filter_by(id=2).first()
|
||||||
|
simulate_user_activity(app.db, user)
|
||||||
|
with login_as_user(app) as client, login_as_user(
|
||||||
|
app, name="admin", password="password"
|
||||||
|
) as admin:
|
||||||
|
req = client.get("/api/v1/challenges")
|
||||||
|
data = req.get_json()["data"]
|
||||||
|
challenge = data[0]
|
||||||
|
assert challenge["solves"] == 1
|
||||||
|
from CTFd.utils.challenges import (
|
||||||
|
get_solves_for_challenge_id,
|
||||||
|
get_solve_counts_for_challenges,
|
||||||
|
)
|
||||||
|
|
||||||
|
solves = get_solves_for_challenge_id(1)
|
||||||
|
solve_counts = get_solve_counts_for_challenges()
|
||||||
|
solves_req = client.get("/api/v1/challenges/1/solves").get_json()["data"]
|
||||||
|
assert len(solves_req) == 1
|
||||||
|
assert len(solves) == 1
|
||||||
|
assert solve_counts[1] == 1
|
||||||
|
|
||||||
|
r = admin.delete("/api/v1/challenges/1", json="")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
solve_counts = get_solve_counts_for_challenges()
|
||||||
|
solves = get_solves_for_challenge_id(1)
|
||||||
|
r = client.get("/api/v1/challenges/1/solves")
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert len(solves) == 0
|
||||||
|
assert solve_counts.get(1) is None
|
||||||
|
destroy_ctfd(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleting_solve_clears_cache():
|
||||||
|
"""
|
||||||
|
Test that deleting a solve clears out the solve count cache
|
||||||
|
"""
|
||||||
|
app = create_ctfd()
|
||||||
|
with app.app_context():
|
||||||
|
register_user(app)
|
||||||
|
user = Users.query.filter_by(id=2).first()
|
||||||
|
simulate_user_activity(app.db, user)
|
||||||
|
with login_as_user(app) as client, login_as_user(
|
||||||
|
app, name="admin", password="password"
|
||||||
|
) as admin:
|
||||||
|
req = client.get("/api/v1/challenges")
|
||||||
|
data = req.get_json()["data"]
|
||||||
|
challenge = data[0]
|
||||||
|
assert challenge["solves"] == 1
|
||||||
|
from CTFd.utils.challenges import (
|
||||||
|
get_solves_for_challenge_id,
|
||||||
|
get_solve_counts_for_challenges,
|
||||||
|
)
|
||||||
|
|
||||||
|
solves = get_solves_for_challenge_id(1)
|
||||||
|
solve_counts = get_solve_counts_for_challenges()
|
||||||
|
solves_req = client.get("/api/v1/challenges/1/solves").get_json()["data"]
|
||||||
|
assert len(solves_req) == 1
|
||||||
|
assert len(solves) == 1
|
||||||
|
assert solve_counts[1] == 1
|
||||||
|
|
||||||
|
r = admin.get("/api/v1/submissions/6", json="")
|
||||||
|
assert r.get_json()["data"]["type"] == "correct"
|
||||||
|
r = admin.delete("/api/v1/submissions/6", json="")
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = admin.get("/api/v1/submissions/6", json="")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
solve_counts = get_solve_counts_for_challenges()
|
||||||
|
solves = get_solves_for_challenge_id(1)
|
||||||
|
solves_req = client.get("/api/v1/challenges/1/solves").get_json()["data"]
|
||||||
|
assert len(solves_req) == 0
|
||||||
|
assert len(solves) == 0
|
||||||
|
assert solve_counts.get(1) is None
|
||||||
|
destroy_ctfd(app)
|
||||||
@@ -15,7 +15,7 @@ from sqlalchemy_utils import drop_database
|
|||||||
from werkzeug.datastructures import Headers
|
from werkzeug.datastructures import Headers
|
||||||
|
|
||||||
from CTFd import create_app
|
from CTFd import create_app
|
||||||
from CTFd.cache import cache, clear_standings
|
from CTFd.cache import cache, clear_challenges, clear_standings
|
||||||
from CTFd.config import TestingConfig
|
from CTFd.config import TestingConfig
|
||||||
from CTFd.models import (
|
from CTFd.models import (
|
||||||
Awards,
|
Awards,
|
||||||
@@ -336,6 +336,7 @@ def gen_challenge(
|
|||||||
)
|
)
|
||||||
db.session.add(chal)
|
db.session.add(chal)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
clear_challenges()
|
||||||
return chal
|
return chal
|
||||||
|
|
||||||
|
|
||||||
@@ -455,6 +456,7 @@ def gen_solve(
|
|||||||
db.session.add(solve)
|
db.session.add(solve)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
clear_standings()
|
clear_standings()
|
||||||
|
clear_challenges()
|
||||||
return solve
|
return solve
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user