mirror of
https://github.com/aljazceru/CTFd.git
synced 2025-12-17 14:04:20 +01:00
3.3.0 (#1833)
# 3.3.0 / UNRELEASED
**General**
- Don't require a team for viewing challenges if Challenge visibility is set to public
- Add a `THEME_FALLBACK` config to help develop themes. See **Themes** section for details.
**API**
- Implement a faster `/api/v1/scoreboard` endpoint in Teams Mode
- Add the `solves` item to both `/api/v1/challenges` and `/api/v1/challenges/[challenge_id]` to more easily determine how many solves a challenge has
- Add the `solved_by_me` item to both `/api/v1/challenges` and `/api/v1/challenges/[challenge_id]` to more easily determine if the current account has solved the challenge
- Prevent admins from deleting themselves through `DELETE /api/v1/users/[user_id]`
- Add length checking to some sensitive fields in the Pages and Challenges schemas
- Fix issue where `PATCH /api/v1/users[user_id]` returned a list instead of a dict
- Fix exception that occured on demoting admins through `PATCH /api/v1/users[user_id]`
- Add `team_id` to `GET /api/v1/users` to determine if a user is already in a team
**Themes**
- Add a `THEME_FALLBACK` config to help develop themes.
- `THEME_FALLBACK` will configure CTFd to try to find missing theme files in the default built-in `core` theme.
- This makes it easier to develop themes or use incomplete themes.
- Allow for one theme to reference and inherit from another theme through approaches like `{% extends "core/page.html" %}`
- Allow for the automatic date rendering format to be overridden by specifying a `data-time-format` attribute.
- Add styling for the `<blockquote>` element.
- Fix scoreboard table identifier to switch between User/Team depending on configured user mode
- Switch to using Bootstrap's scss in `core/main.scss` to allow using Bootstrap variables
- Consolidate Jinja error handlers into a single function and better handle issues where error templates can't be found
**Plugins**
- Set plugin migration version after successful migrations
- Fix issue where Page URLs injected into the navbar were relative instead of absolute
**Admin Panel**
- Add User standings as well as Teams standings to the admin scoreboard when in Teams Mode
- Add a UI for adding members to a team from the team's admin page
- Add ability for admins to disable public team creation
- Link directly to users who submitted something in the submissions page if the CTF is in Teams Mode
- Fix Challenge Requirements interface in Admin Panel to not allow empty/null requirements to be added
- Fixed an issue where config times (start, end, freeze times) could not be removed
- Fix an exception that occurred when demoting an Admin user
- Adds a temporary hack for re-enabling Javascript snippets in Flag editor templates. (See #1779)
**Deployment**
- Install `python3-dev` instead of `python-dev` in apt
- Bump lxml to 4.6.2
- Bump pip-compile to 5.4.0
**Miscellaneous**
- Cache Docker builds more by copying and installing Python dependencies before copying CTFd
- Change the default emails slightly and rework confirmation email page to make some recommendations clearer
- Use `examplectf.com` as testing/development domain instead of `ctfd.io`
- Fixes issue where user's name and email would not appear in logs properly
- Add more linting by also linting with `flake8-comprehensions` and `flake8-bugbear`
This commit is contained in:
60
CHANGELOG.md
60
CHANGELOG.md
@@ -1,3 +1,63 @@
|
||||
# 3.3.0 / UNRELEASED
|
||||
|
||||
**General**
|
||||
|
||||
- Don't require a team for viewing challenges if Challenge visibility is set to public
|
||||
- Add a `THEME_FALLBACK` config to help develop themes. See **Themes** section for details.
|
||||
|
||||
**API**
|
||||
|
||||
- Implement a faster `/api/v1/scoreboard` endpoint in Teams Mode
|
||||
- Add the `solves` item to both `/api/v1/challenges` and `/api/v1/challenges/[challenge_id]` to more easily determine how many solves a challenge has
|
||||
- Add the `solved_by_me` item to both `/api/v1/challenges` and `/api/v1/challenges/[challenge_id]` to more easily determine if the current account has solved the challenge
|
||||
- Prevent admins from deleting themselves through `DELETE /api/v1/users/[user_id]`
|
||||
- Add length checking to some sensitive fields in the Pages and Challenges schemas
|
||||
- Fix issue where `PATCH /api/v1/users[user_id]` returned a list instead of a dict
|
||||
- Fix exception that occured on demoting admins through `PATCH /api/v1/users[user_id]`
|
||||
- Add `team_id` to `GET /api/v1/users` to determine if a user is already in a team
|
||||
|
||||
**Themes**
|
||||
|
||||
- Add a `THEME_FALLBACK` config to help develop themes.
|
||||
- `THEME_FALLBACK` will configure CTFd to try to find missing theme files in the default built-in `core` theme.
|
||||
- This makes it easier to develop themes or use incomplete themes.
|
||||
- Allow for one theme to reference and inherit from another theme through approaches like `{% extends "core/page.html" %}`
|
||||
- Allow for the automatic date rendering format to be overridden by specifying a `data-time-format` attribute.
|
||||
- Add styling for the `<blockquote>` element.
|
||||
- Fix scoreboard table identifier to switch between User/Team depending on configured user mode
|
||||
- Switch to using Bootstrap's scss in `core/main.scss` to allow using Bootstrap variables
|
||||
- Consolidate Jinja error handlers into a single function and better handle issues where error templates can't be found
|
||||
|
||||
**Plugins**
|
||||
|
||||
- Set plugin migration version after successful migrations
|
||||
- Fix issue where Page URLs injected into the navbar were relative instead of absolute
|
||||
|
||||
**Admin Panel**
|
||||
|
||||
- Add User standings as well as Teams standings to the admin scoreboard when in Teams Mode
|
||||
- Add a UI for adding members to a team from the team's admin page
|
||||
- Add ability for admins to disable public team creation
|
||||
- Link directly to users who submitted something in the submissions page if the CTF is in Teams Mode
|
||||
- Fix Challenge Requirements interface in Admin Panel to not allow empty/null requirements to be added
|
||||
- Fixed an issue where config times (start, end, freeze times) could not be removed
|
||||
- Fix an exception that occurred when demoting an Admin user
|
||||
- Adds a temporary hack for re-enabling Javascript snippets in Flag editor templates. (See #1779)
|
||||
|
||||
**Deployment**
|
||||
|
||||
- Install `python3-dev` instead of `python-dev` in apt
|
||||
- Bump lxml to 4.6.2
|
||||
- Bump pip-compile to 5.4.0
|
||||
|
||||
**Miscellaneous**
|
||||
|
||||
- Cache Docker builds more by copying and installing Python dependencies before copying CTFd
|
||||
- Change the default emails slightly and rework confirmation email page to make some recommendations clearer
|
||||
- Use `examplectf.com` as testing/development domain instead of `ctfd.io`
|
||||
- Fixes issue where user's name and email would not appear in logs properly
|
||||
- Add more linting by also linting with `flake8-comprehensions` and `flake8-bugbear`
|
||||
|
||||
# 3.2.1 / 2020-12-09
|
||||
|
||||
- Fixed an issue where Users could not unlock Hints
|
||||
|
||||
@@ -6,13 +6,16 @@ from distutils.version import StrictVersion
|
||||
|
||||
import jinja2
|
||||
from flask import Flask, Request
|
||||
from flask.helpers import safe_join
|
||||
from flask_migrate import upgrade
|
||||
from jinja2 import FileSystemLoader
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from werkzeug.utils import cached_property
|
||||
|
||||
import CTFd.utils.config
|
||||
from CTFd import utils
|
||||
from CTFd.constants.themes import ADMIN_THEME, DEFAULT_THEME
|
||||
from CTFd.plugins import init_plugins
|
||||
from CTFd.utils.crypto import sha256
|
||||
from CTFd.utils.initialization import (
|
||||
@@ -26,7 +29,7 @@ from CTFd.utils.migrations import create_database, migrations, stamp_latest_revi
|
||||
from CTFd.utils.sessions import CachingSessionInterface
|
||||
from CTFd.utils.updates import update_check
|
||||
|
||||
__version__ = "3.2.1"
|
||||
__version__ = "3.3.0"
|
||||
__channel__ = "oss"
|
||||
|
||||
|
||||
@@ -97,26 +100,34 @@ class SandboxedBaseEnvironment(SandboxedEnvironment):
|
||||
|
||||
|
||||
class ThemeLoader(FileSystemLoader):
|
||||
"""Custom FileSystemLoader that switches themes based on the configuration value"""
|
||||
"""Custom FileSystemLoader that is aware of theme structure and config.
|
||||
"""
|
||||
|
||||
def __init__(self, searchpath, encoding="utf-8", followlinks=False):
|
||||
DEFAULT_THEMES_PATH = os.path.join(os.path.dirname(__file__), "themes")
|
||||
_ADMIN_THEME_PREFIX = ADMIN_THEME + "/"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
searchpath=DEFAULT_THEMES_PATH,
|
||||
theme_name=None,
|
||||
encoding="utf-8",
|
||||
followlinks=False,
|
||||
):
|
||||
super(ThemeLoader, self).__init__(searchpath, encoding, followlinks)
|
||||
self.overriden_templates = {}
|
||||
self.theme_name = theme_name
|
||||
|
||||
def get_source(self, environment, template):
|
||||
# Check if the template has been overriden
|
||||
if template in self.overriden_templates:
|
||||
return self.overriden_templates[template], template, lambda: True
|
||||
|
||||
# Check if the template requested is for the admin panel
|
||||
if template.startswith("admin/"):
|
||||
template = template[6:] # Strip out admin/
|
||||
template = "/".join(["admin", "templates", template])
|
||||
return super(ThemeLoader, self).get_source(environment, template)
|
||||
|
||||
# Load regular theme data
|
||||
theme = str(utils.get_config("ctf_theme"))
|
||||
template = "/".join([theme, "templates", template])
|
||||
# Refuse to load `admin/*` from a loader not for the admin theme
|
||||
# Because there is a single template loader, themes can essentially
|
||||
# provide files for other themes. This could end up causing issues if
|
||||
# an admin theme references a file that doesn't exist that a malicious
|
||||
# theme provides.
|
||||
if template.startswith(self._ADMIN_THEME_PREFIX):
|
||||
if self.theme_name != ADMIN_THEME:
|
||||
raise jinja2.TemplateNotFound(template)
|
||||
template = template[len(self._ADMIN_THEME_PREFIX) :]
|
||||
theme_name = self.theme_name or str(utils.get_config("ctf_theme"))
|
||||
template = safe_join(theme_name, "templates", template)
|
||||
return super(ThemeLoader, self).get_source(environment, template)
|
||||
|
||||
|
||||
@@ -144,19 +155,34 @@ def create_app(config="CTFd.config.Config"):
|
||||
with app.app_context():
|
||||
app.config.from_object(config)
|
||||
|
||||
app.theme_loader = ThemeLoader(
|
||||
os.path.join(app.root_path, "themes"), followlinks=True
|
||||
loaders = []
|
||||
# We provide a `DictLoader` which may be used to override templates
|
||||
app.overridden_templates = {}
|
||||
loaders.append(jinja2.DictLoader(app.overridden_templates))
|
||||
# A `ThemeLoader` with no `theme_name` will load from the current theme
|
||||
loaders.append(ThemeLoader())
|
||||
# If `THEME_FALLBACK` is set and true, we add another loader which will
|
||||
# load from the `DEFAULT_THEME` - this mirrors the order implemented by
|
||||
# `config.ctf_theme_candidates()`
|
||||
if bool(app.config.get("THEME_FALLBACK")):
|
||||
loaders.append(ThemeLoader(theme_name=DEFAULT_THEME))
|
||||
# All themes including admin can be accessed by prefixing their name
|
||||
prefix_loader_dict = {ADMIN_THEME: ThemeLoader(theme_name=ADMIN_THEME)}
|
||||
for theme_name in CTFd.utils.config.get_themes():
|
||||
prefix_loader_dict[theme_name] = ThemeLoader(theme_name=theme_name)
|
||||
loaders.append(jinja2.PrefixLoader(prefix_loader_dict))
|
||||
# Plugin templates are also accessed via prefix but we just point a
|
||||
# normal `FileSystemLoader` at the plugin tree rather than validating
|
||||
# each plugin here (that happens later in `init_plugins()`). We
|
||||
# deliberately don't add this to `prefix_loader_dict` defined above
|
||||
# because to do so would break template loading from a theme called
|
||||
# `prefix` (even though that'd be weird).
|
||||
plugin_loader = jinja2.FileSystemLoader(
|
||||
searchpath=os.path.join(app.root_path, "plugins"), followlinks=True
|
||||
)
|
||||
# Weird nested solution for accessing plugin templates
|
||||
app.plugin_loader = jinja2.PrefixLoader(
|
||||
{
|
||||
"plugins": jinja2.FileSystemLoader(
|
||||
searchpath=os.path.join(app.root_path, "plugins"), followlinks=True
|
||||
)
|
||||
}
|
||||
)
|
||||
# Load from themes first but fallback to loading from the plugin folder
|
||||
app.jinja_loader = jinja2.ChoiceLoader([app.theme_loader, app.plugin_loader])
|
||||
loaders.append(jinja2.PrefixLoader({"plugins": plugin_loader}))
|
||||
# Use a choice loader to find the first match from our list of loaders
|
||||
app.jinja_loader = jinja2.ChoiceLoader(loaders)
|
||||
|
||||
from CTFd.models import ( # noqa: F401
|
||||
db,
|
||||
@@ -240,7 +266,7 @@ def create_app(config="CTFd.config.Config"):
|
||||
utils.set_config("ctf_version", __version__)
|
||||
|
||||
if not utils.get_config("ctf_theme"):
|
||||
utils.set_config("ctf_theme", "core")
|
||||
utils.set_config("ctf_theme", DEFAULT_THEME)
|
||||
|
||||
update_check(force=True)
|
||||
|
||||
@@ -258,7 +284,7 @@ def create_app(config="CTFd.config.Config"):
|
||||
from CTFd.admin import admin
|
||||
from CTFd.api import api
|
||||
from CTFd.events import events
|
||||
from CTFd.errors import page_not_found, forbidden, general_error, gateway_error
|
||||
from CTFd.errors import render_error
|
||||
|
||||
app.register_blueprint(views)
|
||||
app.register_blueprint(teams)
|
||||
@@ -271,10 +297,8 @@ def create_app(config="CTFd.config.Config"):
|
||||
|
||||
app.register_blueprint(admin)
|
||||
|
||||
app.register_error_handler(404, page_not_found)
|
||||
app.register_error_handler(403, forbidden)
|
||||
app.register_error_handler(500, general_error)
|
||||
app.register_error_handler(502, gateway_error)
|
||||
for code in {403, 404, 500, 502}:
|
||||
app.register_error_handler(code, render_error)
|
||||
|
||||
init_logs(app)
|
||||
init_events(app)
|
||||
|
||||
@@ -164,7 +164,7 @@ def config():
|
||||
clear_config()
|
||||
|
||||
configs = Configs.query.all()
|
||||
configs = dict([(c.key, get_config(c.key)) for c in configs])
|
||||
configs = {c.key: get_config(c.key) for c in configs}
|
||||
|
||||
themes = ctf_config.get_themes()
|
||||
themes.remove(get_config("ctf_theme"))
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
from flask import render_template
|
||||
|
||||
from CTFd.admin import admin
|
||||
from CTFd.scoreboard import get_standings
|
||||
from CTFd.utils.config import is_teams_mode
|
||||
from CTFd.utils.decorators import admins_only
|
||||
from CTFd.utils.scores import get_standings, get_user_standings
|
||||
|
||||
|
||||
@admin.route("/admin/scoreboard")
|
||||
@admins_only
|
||||
def scoreboard_listing():
|
||||
standings = get_standings(admin=True)
|
||||
return render_template("admin/scoreboard.html", standings=standings)
|
||||
user_standings = get_user_standings(admin=True) if is_teams_mode() else None
|
||||
return render_template(
|
||||
"admin/scoreboard.html", standings=standings, user_standings=user_standings
|
||||
)
|
||||
|
||||
@@ -61,7 +61,7 @@ def statistics():
|
||||
)
|
||||
|
||||
solve_data = {}
|
||||
for chal, count, name in solves:
|
||||
for _chal, count, name in solves:
|
||||
solve_data[name] = count
|
||||
|
||||
most_solved = None
|
||||
|
||||
@@ -3,7 +3,9 @@ from typing import List
|
||||
|
||||
from flask import abort, render_template, request, url_for
|
||||
from flask_restx import Namespace, Resource
|
||||
from sqlalchemy.sql import and_
|
||||
from sqlalchemy import func as sa_func
|
||||
from sqlalchemy import types as sa_types
|
||||
from sqlalchemy.sql import and_, cast, false, true
|
||||
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
@@ -48,7 +50,14 @@ from CTFd.utils.helpers.models import build_model_filters
|
||||
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.user import authed, get_current_team, get_current_user, is_admin
|
||||
from CTFd.utils.user import (
|
||||
authed,
|
||||
get_current_team,
|
||||
get_current_team_attrs,
|
||||
get_current_user,
|
||||
get_current_user_attrs,
|
||||
is_admin,
|
||||
)
|
||||
|
||||
challenges_namespace = Namespace(
|
||||
"challenges", description="Endpoint to retrieve Challenges"
|
||||
@@ -75,6 +84,44 @@ challenges_namespace.schema_model(
|
||||
)
|
||||
|
||||
|
||||
def _build_solves_query(extra_filters=(), admin_view=False):
|
||||
# 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)
|
||||
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.
|
||||
AccountModel = get_model()
|
||||
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),
|
||||
sa_func.sum(cast(user_solved_cond, sa_types.Integer)),
|
||||
)
|
||||
.join(AccountModel)
|
||||
.filter(*extra_filters, freeze_cond, exclude_solves_cond)
|
||||
.group_by(Solves.challenge_id)
|
||||
)
|
||||
return solves_q
|
||||
|
||||
|
||||
@challenges_namespace.route("")
|
||||
class ChallengeList(Resource):
|
||||
@check_challenge_visibility
|
||||
@@ -116,60 +163,68 @@ class ChallengeList(Resource):
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
# Require a team if in teams mode
|
||||
# TODO: Convert this into a re-useable decorator
|
||||
# TODO: The require_team decorator doesnt work because of no admin passthru
|
||||
if get_current_user_attrs():
|
||||
if is_admin():
|
||||
pass
|
||||
else:
|
||||
if config.is_teams_mode() and get_current_team_attrs() is None:
|
||||
abort(403)
|
||||
|
||||
# Build filtering queries
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Challenges, query=q, field=field)
|
||||
|
||||
# This can return None (unauth) if visibility is set to public
|
||||
user = get_current_user()
|
||||
# Admins get a shortcut to see all challenges despite pre-requisites
|
||||
admin_view = is_admin() and request.args.get("view") == "admin"
|
||||
|
||||
# Admins can request to see everything
|
||||
if is_admin() and request.args.get("view") == "admin":
|
||||
challenges = (
|
||||
Challenges.query.filter_by(**query_args)
|
||||
.filter(*filters)
|
||||
.order_by(Challenges.value)
|
||||
.all()
|
||||
)
|
||||
solve_ids = set([challenge.id for challenge in challenges])
|
||||
solve_counts, user_solves = {}, set()
|
||||
# Build a query for to show challenge solve information. We only
|
||||
# give an admin view if the request argument has been provided.
|
||||
#
|
||||
# NOTE: This is different behaviour to the challenge detail
|
||||
# endpoint which only needs the current user to be an admin rather
|
||||
# than also also having to provide `view=admin` as a query arg.
|
||||
solves_q = _build_solves_query(admin_view=admin_view)
|
||||
# Aggregate the query results into the hashes defined at the top of
|
||||
# this block for later use
|
||||
for chal_id, solve_count, solved_by_user in solves_q:
|
||||
solve_counts[chal_id] = solve_count
|
||||
if solved_by_user:
|
||||
user_solves.add(chal_id)
|
||||
if scores_visible() and accounts_visible():
|
||||
solve_count_dfl = 0
|
||||
else:
|
||||
challenges = (
|
||||
Challenges.query.filter(
|
||||
and_(Challenges.state != "hidden", Challenges.state != "locked")
|
||||
)
|
||||
.filter_by(**query_args)
|
||||
.filter(*filters)
|
||||
.order_by(Challenges.value)
|
||||
.all()
|
||||
# Empty out the solves_count if we're hiding scores/accounts
|
||||
solve_counts = {}
|
||||
# This is necessary to match the challenge detail API which returns
|
||||
# `None` for the solve count if visiblity checks fail
|
||||
solve_count_dfl = None
|
||||
|
||||
# Build the query for the challenges which may be listed
|
||||
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)
|
||||
)
|
||||
|
||||
if user:
|
||||
solve_ids = (
|
||||
Solves.query.with_entities(Solves.challenge_id)
|
||||
.filter_by(account_id=user.account_id)
|
||||
.order_by(Solves.challenge_id.asc())
|
||||
.all()
|
||||
)
|
||||
solve_ids = set([value for value, in solve_ids])
|
||||
|
||||
# TODO: Convert this into a re-useable decorator
|
||||
if is_admin():
|
||||
pass
|
||||
else:
|
||||
if config.is_teams_mode() and get_current_team() is None:
|
||||
abort(403)
|
||||
else:
|
||||
solve_ids = set()
|
||||
|
||||
# Iterate through the list of challenges, adding to the object which
|
||||
# will be JSONified back to the client
|
||||
response = []
|
||||
tag_schema = TagSchema(view="user", many=True)
|
||||
for challenge in challenges:
|
||||
for challenge in chal_q:
|
||||
if challenge.requirements:
|
||||
requirements = challenge.requirements.get("prerequisites", [])
|
||||
anonymize = challenge.requirements.get("anonymize")
|
||||
prereqs = set(requirements)
|
||||
if solve_ids >= prereqs:
|
||||
if user_solves >= prereqs:
|
||||
pass
|
||||
else:
|
||||
if anonymize:
|
||||
@@ -179,6 +234,8 @@ class ChallengeList(Resource):
|
||||
"type": "hidden",
|
||||
"name": "???",
|
||||
"value": 0,
|
||||
"solves": None,
|
||||
"solved_by_me": False,
|
||||
"category": "???",
|
||||
"tags": [],
|
||||
"template": "",
|
||||
@@ -201,6 +258,8 @@ class ChallengeList(Resource):
|
||||
"type": challenge_type.name,
|
||||
"name": challenge.name,
|
||||
"value": challenge.value,
|
||||
"solves": solve_counts.get(challenge.id, solve_count_dfl),
|
||||
"solved_by_me": challenge.id in user_solves,
|
||||
"category": challenge.category,
|
||||
"tags": tag_schema.dump(challenge.tags).data,
|
||||
"template": challenge_type.templates["view"],
|
||||
@@ -305,7 +364,7 @@ class Challenge(Resource):
|
||||
else:
|
||||
# We need to handle the case where a user is viewing challenges anonymously
|
||||
solve_ids = []
|
||||
solve_ids = set([value for value, in solve_ids])
|
||||
solve_ids = {value for value, in solve_ids}
|
||||
prereqs = set(requirements)
|
||||
if solve_ids >= prereqs or is_admin():
|
||||
pass
|
||||
@@ -318,6 +377,8 @@ class Challenge(Resource):
|
||||
"type": "hidden",
|
||||
"name": "???",
|
||||
"value": 0,
|
||||
"solves": None,
|
||||
"solved_by_me": False,
|
||||
"category": "???",
|
||||
"tags": [],
|
||||
"template": "",
|
||||
@@ -345,14 +406,12 @@ class Challenge(Resource):
|
||||
if config.is_teams_mode() and team is None:
|
||||
abort(403)
|
||||
|
||||
unlocked_hints = set(
|
||||
[
|
||||
u.target
|
||||
for u in HintUnlocks.query.filter_by(
|
||||
type="hints", account_id=user.account_id
|
||||
)
|
||||
]
|
||||
)
|
||||
unlocked_hints = {
|
||||
u.target
|
||||
for u in HintUnlocks.query.filter_by(
|
||||
type="hints", account_id=user.account_id
|
||||
)
|
||||
}
|
||||
files = []
|
||||
for f in chal.files:
|
||||
token = {
|
||||
@@ -376,25 +435,20 @@ class Challenge(Resource):
|
||||
|
||||
response = chal_class.read(challenge=chal)
|
||||
|
||||
Model = get_model()
|
||||
|
||||
if scores_visible() is True and accounts_visible() is True:
|
||||
solves = Solves.query.join(Model, Solves.account_id == Model.id).filter(
|
||||
Solves.challenge_id == chal.id,
|
||||
Model.banned == False,
|
||||
Model.hidden == False,
|
||||
)
|
||||
|
||||
# Only show solves that happened before freeze time if configured
|
||||
freeze = get_config("freeze")
|
||||
if not is_admin() and freeze:
|
||||
solves = solves.filter(Solves.date < unix_time_to_utc(freeze))
|
||||
|
||||
solves = solves.count()
|
||||
response["solves"] = solves
|
||||
solves_q = _build_solves_query(
|
||||
admin_view=is_admin(), extra_filters=(Solves.challenge_id == chal.id,)
|
||||
)
|
||||
# If there are no solves for this challenge ID then we have 0 rows
|
||||
maybe_row = solves_q.first()
|
||||
if maybe_row:
|
||||
_, solve_count, solved_by_user = maybe_row
|
||||
solved_by_user = bool(solved_by_user)
|
||||
else:
|
||||
response["solves"] = None
|
||||
solves = None
|
||||
solve_count, solved_by_user = 0, False
|
||||
|
||||
# Hide solve counts if we are hiding solves/accounts
|
||||
if scores_visible() is False or accounts_visible() is False:
|
||||
solve_count = None
|
||||
|
||||
if authed():
|
||||
# Get current attempts for the user
|
||||
@@ -404,6 +458,8 @@ class Challenge(Resource):
|
||||
else:
|
||||
attempts = 0
|
||||
|
||||
response["solves"] = solve_count
|
||||
response["solved_by_me"] = solved_by_user
|
||||
response["attempts"] = attempts
|
||||
response["files"] = files
|
||||
response["tags"] = tags
|
||||
@@ -411,7 +467,8 @@ class Challenge(Resource):
|
||||
|
||||
response["view"] = render_template(
|
||||
chal_class.templates["view"].lstrip("/"),
|
||||
solves=solves,
|
||||
solves=solve_count,
|
||||
solved_by_me=solved_by_user,
|
||||
files=files,
|
||||
tags=tags,
|
||||
hints=[Hints(**h) for h in hints],
|
||||
@@ -532,7 +589,7 @@ class ChallengeAttempt(Resource):
|
||||
.order_by(Solves.challenge_id.asc())
|
||||
.all()
|
||||
)
|
||||
solve_ids = set([solve_id for solve_id, in solve_ids])
|
||||
solve_ids = {solve_id for solve_id, in solve_ids}
|
||||
prereqs = set(requirements)
|
||||
if solve_ids >= prereqs:
|
||||
pass
|
||||
|
||||
@@ -6,11 +6,13 @@ from sqlalchemy.orm.properties import ColumnProperty
|
||||
|
||||
|
||||
def sqlalchemy_to_pydantic(
|
||||
db_model: Type, *, exclude: Container[str] = []
|
||||
db_model: Type, *, exclude: Container[str] = None
|
||||
) -> Type[BaseModel]:
|
||||
"""
|
||||
Mostly copied from https://github.com/tiangolo/pydantic-sqlalchemy
|
||||
"""
|
||||
if exclude is None:
|
||||
exclude = []
|
||||
mapper = inspect(db_model)
|
||||
fields = {}
|
||||
for attr in mapper.attrs:
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from collections import defaultdict
|
||||
|
||||
from flask_restx import Namespace, Resource
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy import select
|
||||
|
||||
from CTFd.cache import cache, make_cache_key
|
||||
from CTFd.models import Awards, Solves, Teams
|
||||
from CTFd.models import Awards, Solves, Users, db
|
||||
from CTFd.utils import get_config
|
||||
from CTFd.utils.dates import isoformat, unix_time_to_utc
|
||||
from CTFd.utils.decorators.visibility import (
|
||||
@@ -31,25 +31,33 @@ class ScoreboardList(Resource):
|
||||
account_type = get_mode_as_word()
|
||||
|
||||
if mode == TEAMS_MODE:
|
||||
team_ids = []
|
||||
for team in standings:
|
||||
team_ids.append(team.account_id)
|
||||
|
||||
# Get team objects with members explicitly loaded in
|
||||
teams = (
|
||||
Teams.query.options(joinedload(Teams.members))
|
||||
.filter(Teams.id.in_(team_ids))
|
||||
.all()
|
||||
r = db.session.execute(
|
||||
select(
|
||||
[
|
||||
Users.id,
|
||||
Users.name,
|
||||
Users.oauth_id,
|
||||
Users.team_id,
|
||||
Users.hidden,
|
||||
Users.banned,
|
||||
]
|
||||
).where(Users.team_id.isnot(None))
|
||||
)
|
||||
|
||||
# Sort according to team_ids order
|
||||
teams = [next(t for t in teams if t.id == id) for id in team_ids]
|
||||
users = r.fetchall()
|
||||
membership = defaultdict(dict)
|
||||
for u in users:
|
||||
if u.hidden is False and u.banned is False:
|
||||
membership[u.team_id][u.id] = {
|
||||
"id": u.id,
|
||||
"oauth_id": u.oauth_id,
|
||||
"name": u.name,
|
||||
"score": 0,
|
||||
}
|
||||
|
||||
# Get user_standings as a dict so that we can more quickly get member scores
|
||||
user_standings = get_user_standings()
|
||||
users = {}
|
||||
for u in user_standings:
|
||||
users[u.user_id] = u
|
||||
membership[u.team_id][u.user_id]["score"] = int(u.score)
|
||||
|
||||
for i, x in enumerate(standings):
|
||||
entry = {
|
||||
@@ -63,33 +71,7 @@ class ScoreboardList(Resource):
|
||||
}
|
||||
|
||||
if mode == TEAMS_MODE:
|
||||
members = []
|
||||
|
||||
# This code looks like it would be slow
|
||||
# but it is faster than accessing each member's score individually
|
||||
for member in teams[i].members:
|
||||
user = users.get(member.id)
|
||||
if user:
|
||||
members.append(
|
||||
{
|
||||
"id": user.user_id,
|
||||
"oauth_id": user.oauth_id,
|
||||
"name": user.name,
|
||||
"score": int(user.score),
|
||||
}
|
||||
)
|
||||
else:
|
||||
if member.hidden is False and member.banned is False:
|
||||
members.append(
|
||||
{
|
||||
"id": member.id,
|
||||
"oauth_id": member.oauth_id,
|
||||
"name": member.name,
|
||||
"score": 0,
|
||||
}
|
||||
)
|
||||
|
||||
entry["members"] = members
|
||||
entry["members"] = list(membership[x.account_id].values())
|
||||
|
||||
response.append(entry)
|
||||
return {"success": True, "data": response}
|
||||
@@ -152,7 +134,7 @@ class ScoreboardDetail(Resource):
|
||||
solves_mapper[team_id], key=lambda k: k["date"]
|
||||
)
|
||||
|
||||
for i, team in enumerate(team_ids):
|
||||
for i, _team in enumerate(team_ids):
|
||||
response[i + 1] = {
|
||||
"id": standings[i].account_id,
|
||||
"name": standings[i].name,
|
||||
|
||||
@@ -132,7 +132,6 @@ class UserList(Resource):
|
||||
"data": response.data,
|
||||
}
|
||||
|
||||
@users_namespace.doc()
|
||||
@admins_only
|
||||
@users_namespace.doc(
|
||||
description="Endpoint to create a User object",
|
||||
|
||||
@@ -143,6 +143,11 @@ LOG_FOLDER =
|
||||
# If you specify `true` CTFd will default to the above behavior with all proxy settings set to 1.
|
||||
REVERSE_PROXY =
|
||||
|
||||
# THEME_FALLBACK
|
||||
# Specifies whether CTFd will fallback to the default "core" theme for missing pages/content. Useful for developing themes or using incomplete themes.
|
||||
# Defaults to false.
|
||||
THEME_FALLBACK =
|
||||
|
||||
# TEMPLATES_AUTO_RELOAD
|
||||
# Specifies whether Flask should check for modifications to templates and reload them automatically. Defaults to true.
|
||||
TEMPLATES_AUTO_RELOAD =
|
||||
|
||||
@@ -55,7 +55,7 @@ def gen_secret_key():
|
||||
try:
|
||||
with open(".ctfd_secret_key", "rb") as secret:
|
||||
key = secret.read()
|
||||
except (OSError, IOError):
|
||||
except OSError:
|
||||
key = None
|
||||
|
||||
if not key:
|
||||
@@ -66,7 +66,7 @@ def gen_secret_key():
|
||||
with open(".ctfd_secret_key", "wb") as secret:
|
||||
secret.write(key)
|
||||
secret.flush()
|
||||
except (OSError, IOError):
|
||||
except OSError:
|
||||
pass
|
||||
return key
|
||||
|
||||
@@ -178,6 +178,8 @@ class ServerConfig(object):
|
||||
|
||||
TEMPLATES_AUTO_RELOAD: bool = empty_str_cast(config_ini["optional"]["TEMPLATES_AUTO_RELOAD"], default=True)
|
||||
|
||||
THEME_FALLBACK: bool = empty_str_cast(config_ini["optional"]["THEME_FALLBACK"], default=False)
|
||||
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS: bool = empty_str_cast(config_ini["optional"]["SQLALCHEMY_TRACK_MODIFICATIONS"], default=False)
|
||||
|
||||
SWAGGER_UI: bool = empty_str_cast(config_ini["optional"]["SWAGGER_UI"], default=False)
|
||||
|
||||
2
CTFd/constants/themes.py
Normal file
2
CTFd/constants/themes.py
Normal file
@@ -0,0 +1,2 @@
|
||||
ADMIN_THEME = "admin"
|
||||
DEFAULT_THEME = "core"
|
||||
@@ -1,25 +1,20 @@
|
||||
import jinja2.exceptions
|
||||
from flask import render_template
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
|
||||
|
||||
# 404
|
||||
def page_not_found(error):
|
||||
return render_template("errors/404.html", error=error.description), 404
|
||||
|
||||
|
||||
# 403
|
||||
def forbidden(error):
|
||||
return render_template("errors/403.html", error=error.description), 403
|
||||
|
||||
|
||||
# 500
|
||||
def general_error(error):
|
||||
if error.description == InternalServerError.description:
|
||||
def render_error(error):
|
||||
if (
|
||||
isinstance(error, InternalServerError)
|
||||
and error.description == InternalServerError.description
|
||||
):
|
||||
error.description = "An Internal Server Error has occurred"
|
||||
|
||||
return render_template("errors/500.html", error=error.description), 500
|
||||
|
||||
|
||||
# 502
|
||||
def gateway_error(error):
|
||||
return render_template("errors/502.html", error=error.description), 502
|
||||
try:
|
||||
return (
|
||||
render_template(
|
||||
"errors/{}.html".format(error.code), error=error.description,
|
||||
),
|
||||
error.code,
|
||||
)
|
||||
except jinja2.exceptions.TemplateNotFound:
|
||||
return error.get_response()
|
||||
|
||||
@@ -10,6 +10,7 @@ from wtforms import (
|
||||
from wtforms.fields.html5 import EmailField
|
||||
from wtforms.validators import InputRequired
|
||||
|
||||
from CTFd.constants.themes import DEFAULT_THEME
|
||||
from CTFd.forms import BaseForm
|
||||
from CTFd.forms.fields import SubmitField
|
||||
from CTFd.utils.config import get_themes
|
||||
@@ -59,7 +60,7 @@ class SetupForm(BaseForm):
|
||||
"Theme",
|
||||
description="CTFd Theme to use",
|
||||
choices=list(zip(get_themes(), get_themes())),
|
||||
default="core",
|
||||
default=DEFAULT_THEME,
|
||||
validators=[InputRequired()],
|
||||
)
|
||||
theme_color = HiddenField(
|
||||
|
||||
@@ -123,7 +123,7 @@ class BaseChallenge(object):
|
||||
if get_flag_class(flag.type).compare(flag, submission):
|
||||
return True, "Correct"
|
||||
except FlagException as e:
|
||||
return False, e.message
|
||||
return False, str(e)
|
||||
return False, "Incorrect"
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -316,6 +316,7 @@ class UserSchema(ma.ModelSchema):
|
||||
"id",
|
||||
"oauth_id",
|
||||
"fields",
|
||||
"team_id",
|
||||
],
|
||||
"self": [
|
||||
"website",
|
||||
@@ -328,6 +329,7 @@ class UserSchema(ma.ModelSchema):
|
||||
"oauth_id",
|
||||
"password",
|
||||
"fields",
|
||||
"team_id",
|
||||
],
|
||||
"admin": [
|
||||
"website",
|
||||
@@ -346,6 +348,7 @@ class UserSchema(ma.ModelSchema):
|
||||
"type",
|
||||
"verified",
|
||||
"fields",
|
||||
"team_id",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
229
CTFd/themes/admin/assets/js/components/teams/UserAddForm.vue
Normal file
229
CTFd/themes/admin/assets/js/components/teams/UserAddForm.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label>Search Users</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search for users"
|
||||
v-model="searchedName"
|
||||
@keyup.down="moveCursor('down')"
|
||||
@keyup.up="moveCursor('up')"
|
||||
@keyup.enter="selectUser()"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span
|
||||
class="badge badge-primary mr-1"
|
||||
v-for="user in selectedUsers"
|
||||
:key="user.id"
|
||||
>
|
||||
{{ user.name }}
|
||||
<a class="btn-fa" @click="removeSelectedUser(user.id)"> ×</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div
|
||||
class="text-center"
|
||||
v-if="
|
||||
userResults.length == 0 &&
|
||||
this.searchedName != '' &&
|
||||
awaitingSearch == false
|
||||
"
|
||||
>
|
||||
<span class="text-muted">
|
||||
No users found
|
||||
</span>
|
||||
</div>
|
||||
<ul class="list-group">
|
||||
<li
|
||||
:class="{
|
||||
'list-group-item': true,
|
||||
active: idx === selectedResultIdx
|
||||
}"
|
||||
v-for="(user, idx) in userResults"
|
||||
:key="user.id"
|
||||
@click="selectUser(idx)"
|
||||
>
|
||||
{{ user.name }}
|
||||
<small
|
||||
v-if="user.team_id"
|
||||
:class="{
|
||||
'float-right': true,
|
||||
'text-white': idx === selectedResultIdx,
|
||||
'text-muted': idx !== selectedResultIdx
|
||||
}"
|
||||
>
|
||||
already in a team
|
||||
</small>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button
|
||||
class="btn btn-success d-inline-block float-right"
|
||||
@click="addUsers()"
|
||||
>
|
||||
Add Users
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CTFd from "core/CTFd";
|
||||
import { ezQuery } from "core/ezq";
|
||||
import { htmlEntities } from "core/utils";
|
||||
|
||||
export default {
|
||||
name: "UserAddForm",
|
||||
props: {
|
||||
team_id: Number
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
searchedName: "",
|
||||
awaitingSearch: false,
|
||||
emptyResults: false,
|
||||
userResults: [],
|
||||
selectedResultIdx: 0,
|
||||
selectedUsers: []
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
searchUsers: function() {
|
||||
this.selectedResultIdx = 0;
|
||||
if (this.searchedName == "") {
|
||||
this.userResults = [];
|
||||
return;
|
||||
}
|
||||
|
||||
CTFd.fetch(`/api/v1/users?view=admin&field=name&q=${this.searchedName}`, {
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
return response.json();
|
||||
})
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
this.userResults = response.data.slice(0, 10);
|
||||
}
|
||||
});
|
||||
},
|
||||
moveCursor: function(dir) {
|
||||
switch (dir) {
|
||||
case "up":
|
||||
if (this.selectedResultIdx) {
|
||||
this.selectedResultIdx -= 1;
|
||||
}
|
||||
break;
|
||||
case "down":
|
||||
if (this.selectedResultIdx < this.userResults.length - 1) {
|
||||
this.selectedResultIdx += 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
selectUser: function(idx) {
|
||||
if (idx === undefined) {
|
||||
idx = this.selectedResultIdx;
|
||||
}
|
||||
let user = this.userResults[idx];
|
||||
|
||||
// Avoid duplicates
|
||||
const found = this.selectedUsers.some(
|
||||
searchUser => searchUser.id === user.id
|
||||
);
|
||||
if (found === false) {
|
||||
this.selectedUsers.push(user);
|
||||
}
|
||||
|
||||
this.userResults = [];
|
||||
this.searchedName = "";
|
||||
},
|
||||
removeSelectedUser: function(user_id) {
|
||||
this.selectedUsers = this.selectedUsers.filter(
|
||||
user => user.id !== user_id
|
||||
);
|
||||
},
|
||||
handleAddUsersRequest: function() {
|
||||
let reqs = [];
|
||||
|
||||
this.selectedUsers.forEach(user => {
|
||||
let body = { user_id: user.id };
|
||||
reqs.push(
|
||||
CTFd.fetch(`/api/v1/teams/${this.$props.team_id}/members`, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
return Promise.all(reqs);
|
||||
},
|
||||
handleRemoveUsersFromTeams: function() {
|
||||
let reqs = [];
|
||||
this.selectedUsers.forEach(user => {
|
||||
let body = { user_id: user.id };
|
||||
reqs.push(
|
||||
CTFd.fetch(`/api/v1/teams/${user.team_id}/members`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
);
|
||||
});
|
||||
return Promise.all(reqs);
|
||||
},
|
||||
addUsers: function() {
|
||||
let usersInTeams = [];
|
||||
this.selectedUsers.forEach(user => {
|
||||
if (user.team_id) {
|
||||
usersInTeams.push(user.name);
|
||||
}
|
||||
});
|
||||
if (usersInTeams.length) {
|
||||
let users = htmlEntities(usersInTeams.join(", "));
|
||||
ezQuery({
|
||||
title: "Confirm Team Removal",
|
||||
body: `The following users are currently in teams:<br><br> ${users} <br><br>Are you sure you want to remove them from their current teams and add them to this one? <br><br>All of their challenge solves, attempts, awards, and unlocked hints will also be deleted!`,
|
||||
success: () => {
|
||||
this.handleRemoveUsersFromTeams().then(_resps => {
|
||||
this.handleAddUsersRequest().then(_resps => {
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.handleAddUsersRequest().then(_resps => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
searchedName: function(val) {
|
||||
if (this.awaitingSearch === false) {
|
||||
// 1 second delay after typing
|
||||
setTimeout(() => {
|
||||
this.searchUsers();
|
||||
this.awaitingSearch = false;
|
||||
}, 1000);
|
||||
}
|
||||
this.awaitingSearch = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -38,23 +38,42 @@ function toggleAccount() {
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSelectedAccounts(accountIDs, action) {
|
||||
function toggleSelectedAccounts(selectedAccounts, action) {
|
||||
const params = {
|
||||
hidden: action === "hidden" ? true : false
|
||||
};
|
||||
const reqs = [];
|
||||
for (var accId of accountIDs) {
|
||||
for (let accId of selectedAccounts.accounts) {
|
||||
reqs.push(api_func[CTFd.config.userMode](accId, params));
|
||||
}
|
||||
for (let accId of selectedAccounts.users) {
|
||||
reqs.push(api_func["users"](accId, params));
|
||||
}
|
||||
Promise.all(reqs).then(_responses => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
function bulkToggleAccounts(_event) {
|
||||
let accountIDs = $("input[data-account-id]:checked").map(function() {
|
||||
return $(this).data("account-id");
|
||||
});
|
||||
// Get selected account and user IDs but only on the active tab.
|
||||
// Technically this could work for both tabs at the same time but that seems like
|
||||
// bad behavior. We don't want to accidentally unhide a user/team accidentally
|
||||
let accountIDs = $(".tab-pane.active input[data-account-id]:checked").map(
|
||||
function() {
|
||||
return $(this).data("account-id");
|
||||
}
|
||||
);
|
||||
|
||||
let userIDs = $(".tab-pane.active input[data-user-id]:checked").map(
|
||||
function() {
|
||||
return $(this).data("user-id");
|
||||
}
|
||||
);
|
||||
|
||||
let selectedUsers = {
|
||||
accounts: accountIDs,
|
||||
users: userIDs
|
||||
};
|
||||
|
||||
ezAlert({
|
||||
title: "Toggle Visibility",
|
||||
@@ -74,7 +93,7 @@ function bulkToggleAccounts(_event) {
|
||||
success: function() {
|
||||
let data = $("#scoreboard-bulk-edit").serializeJSON(true);
|
||||
let state = data.visibility;
|
||||
toggleSelectedAccounts(accountIDs, state);
|
||||
toggleSelectedAccounts(selectedUsers, state);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ezAlert, ezQuery, ezBadge } from "core/ezq";
|
||||
import { createGraph, updateGraph } from "core/graphs";
|
||||
import Vue from "vue/dist/vue.esm.browser";
|
||||
import CommentBox from "../components/comments/CommentBox.vue";
|
||||
import UserAddForm from "../components/teams/UserAddForm.vue";
|
||||
import { copyToClipboard } from "../../../../core/assets/js/utils";
|
||||
|
||||
function createTeam(event) {
|
||||
@@ -398,6 +399,10 @@ $(() => {
|
||||
copyToClipboard(e, "#team-invite-link");
|
||||
});
|
||||
|
||||
$(".members-team").click(function(_e) {
|
||||
$("#team-add-modal").modal("toggle");
|
||||
});
|
||||
|
||||
$(".edit-captain").click(function(_e) {
|
||||
$("#team-captain-modal").modal("toggle");
|
||||
});
|
||||
@@ -477,7 +482,7 @@ $(() => {
|
||||
|
||||
ezQuery({
|
||||
title: "Remove Member",
|
||||
body: "Are you sure you want to remove {0} from {1}? <br><br><strong>All of their challenges solves, attempts, awards, and unlocked hints will also be deleted!</strong>".format(
|
||||
body: "Are you sure you want to remove {0} from {1}? <br><br><strong>All of their challenge solves, attempts, awards, and unlocked hints will also be deleted!</strong>".format(
|
||||
"<strong>" + htmlEntities(member_name) + "</strong>",
|
||||
"<strong>" + htmlEntities(window.TEAM_NAME) + "</strong>"
|
||||
),
|
||||
@@ -548,6 +553,16 @@ $(() => {
|
||||
propsData: { type: "team", id: window.TEAM_ID }
|
||||
}).$mount(vueContainer);
|
||||
|
||||
// Insert team member addition form
|
||||
const userAddForm = Vue.extend(UserAddForm);
|
||||
let memberFormContainer = document.createElement("div");
|
||||
document
|
||||
.querySelector("#team-add-modal .modal-body")
|
||||
.appendChild(memberFormContainer);
|
||||
new userAddForm({
|
||||
propsData: { team_id: window.TEAM_ID }
|
||||
}).$mount(memberFormContainer);
|
||||
|
||||
let type, id, name, account_id;
|
||||
({ type, id, name, account_id } = window.stats_data);
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -78,7 +78,7 @@
|
||||
<h5 class="modal-title">Theme Settings</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{% include "config.html" ignore missing %}
|
||||
{% include ctf_theme + "/config.html" ignore missing %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,59 +18,38 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if Configs.user_mode == UserModeTypes.TEAMS %}
|
||||
<div class="row pb-4">
|
||||
<div class="col-md-12">
|
||||
<ul class="nav nav-tabs nav-fill" role="tablist">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" data-toggle="tab" href="#standings" role="tab">
|
||||
Teams
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-toggle="tab" href="#user-standings" role="tab">
|
||||
Users
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<table id="scoreboard" class="table table-striped border">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border-right" data-checkbox>
|
||||
<div class="form-check text-center">
|
||||
<input type="checkbox" class="form-check-input" id="scoreboard-bulk-select" data-checkbox-all>
|
||||
</div>
|
||||
</th>
|
||||
<th class="sort-col text-center"><b>Place</b></th>
|
||||
<th class="sort-col"><b>{{ get_mode_as_word(capitalize=True) }}</b></th>
|
||||
<th class="sort-col"><b>Score</b></th>
|
||||
<th class="sort-col"><b>Visibility</b></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for standing in standings %}
|
||||
<tr data-href="{{ generate_account_url(standing.account_id, admin=True) }}">
|
||||
<td class="border-right text-center" data-checkbox>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" value="{{ standing.account_id }}" data-account-id="{{ standing.account_id }}">
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center" width="10%">{{ loop.index }}</td>
|
||||
<td>
|
||||
<a href="{{ generate_account_url(standing.account_id, admin=True) }}">
|
||||
{{ standing.name }}
|
||||
{% if standing.oauth_id %}
|
||||
{% if get_config('user_mode') == 'teams' %}
|
||||
<a href="https://majorleaguecyber.org/t/{{ standing.name }}">
|
||||
<span class="badge badge-primary">Official</span>
|
||||
</a>
|
||||
{% elif get_config('user_mode') == 'users' %}
|
||||
<a href="https://majorleaguecyber.org/u/{{ standing.name }}">
|
||||
<span class="badge badge-primary">Official</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ standing.score }}</td>
|
||||
<td>
|
||||
{% if standing.hidden %}
|
||||
<span class="badge badge-danger">hidden</span>
|
||||
{% else %}
|
||||
<span class="badge badge-success">visible</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="standings" role="tabpanel">
|
||||
{% include "admin/scoreboard/standings.html" %}
|
||||
</div>
|
||||
{% if Configs.user_mode == UserModeTypes.TEAMS %}
|
||||
<div class="tab-pane fade" id="user-standings" role="tabpanel">
|
||||
{% include "admin/scoreboard/users.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
51
CTFd/themes/admin/templates/scoreboard/standings.html
Normal file
51
CTFd/themes/admin/templates/scoreboard/standings.html
Normal file
@@ -0,0 +1,51 @@
|
||||
<table id="scoreboard" class="table table-striped border">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border-right" data-checkbox>
|
||||
<div class="form-check text-center">
|
||||
<input type="checkbox" class="form-check-input" id="scoreboard-bulk-select" data-checkbox-all>
|
||||
</div>
|
||||
</th>
|
||||
<th class="sort-col text-center"><b>Place</b></th>
|
||||
<th class="sort-col"><b>{{ get_mode_as_word(capitalize=True) }}</b></th>
|
||||
<th class="sort-col"><b>Score</b></th>
|
||||
<th class="sort-col"><b>Visibility</b></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for standing in standings %}
|
||||
<tr data-href="{{ generate_account_url(standing.account_id, admin=True) }}">
|
||||
<td class="border-right text-center" data-checkbox>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" value="{{ standing.account_id }}" data-account-id="{{ standing.account_id }}">
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center" width="10%">{{ loop.index }}</td>
|
||||
<td>
|
||||
<a href="{{ generate_account_url(standing.account_id, admin=True) }}">
|
||||
{{ standing.name }}
|
||||
{% if standing.oauth_id %}
|
||||
{% if get_config('user_mode') == 'teams' %}
|
||||
<a href="https://majorleaguecyber.org/t/{{ standing.name }}">
|
||||
<span class="badge badge-primary">Official</span>
|
||||
</a>
|
||||
{% elif get_config('user_mode') == 'users' %}
|
||||
<a href="https://majorleaguecyber.org/u/{{ standing.name }}">
|
||||
<span class="badge badge-primary">Official</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ standing.score }}</td>
|
||||
<td>
|
||||
{% if standing.hidden %}
|
||||
<span class="badge badge-danger">hidden</span>
|
||||
{% else %}
|
||||
<span class="badge badge-success">visible</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
51
CTFd/themes/admin/templates/scoreboard/users.html
Normal file
51
CTFd/themes/admin/templates/scoreboard/users.html
Normal file
@@ -0,0 +1,51 @@
|
||||
<table id="scoreboard" class="table table-striped border">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border-right" data-checkbox>
|
||||
<div class="form-check text-center">
|
||||
<input type="checkbox" class="form-check-input" id="scoreboard-bulk-select" data-checkbox-all>
|
||||
</div>
|
||||
</th>
|
||||
<th class="sort-col text-center"><b>Place</b></th>
|
||||
<th class="sort-col"><b>User</b></th>
|
||||
<th class="sort-col"><b>Score</b></th>
|
||||
<th class="sort-col"><b>Visibility</b></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for standing in user_standings %}
|
||||
<tr data-href="{{ url_for('admin.users_detail', user_id=standing.user_id) }}">
|
||||
<td class="border-right text-center" data-checkbox>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" value="{{ standing.user_id }}" data-user-id="{{ standing.user_id }}">
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center" width="10%">{{ loop.index }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('admin.users_detail', user_id=standing.user_id) }}">
|
||||
{{ standing.name }}
|
||||
{% if standing.oauth_id %}
|
||||
{% if get_config('user_mode') == 'teams' %}
|
||||
<a href="https://majorleaguecyber.org/t/{{ standing.name }}">
|
||||
<span class="badge badge-primary">Official</span>
|
||||
</a>
|
||||
{% elif get_config('user_mode') == 'users' %}
|
||||
<a href="https://majorleaguecyber.org/u/{{ standing.name }}">
|
||||
<span class="badge badge-primary">Official</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ standing.score }}</td>
|
||||
<td>
|
||||
{% if standing.hidden %}
|
||||
<span class="badge badge-danger">hidden</span>
|
||||
{% else %}
|
||||
<span class="badge badge-success">visible</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -20,6 +20,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="team-add-modal" class="modal fade">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-action text-center w-100">Add Team Members</h2>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body clearfix">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="team-invite-modal" class="modal fade">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
@@ -199,9 +214,9 @@
|
||||
<i class="btn-fa fas fa-ticket-alt fa-2x px-2" data-toggle="tooltip" data-placement="top"
|
||||
title="Team Invite Link"></i>
|
||||
</a>
|
||||
<a class="statistics-team text-dark">
|
||||
<i class="btn-fa fas fa-chart-pie fa-2x px-2" data-toggle="tooltip" data-placement="top"
|
||||
title="Team Statistics"></i>
|
||||
<a class="members-team text-dark">
|
||||
<i class="btn-fa fas fa-user-plus fa-2x px-2" data-toggle="tooltip" data-placement="top"
|
||||
title="Add Team Members"></i>
|
||||
</a>
|
||||
<a class="edit-captain text-dark">
|
||||
<i class="btn-fa fas fa-user-tag fa-2x px-2" data-toggle="tooltip" data-placement="top"
|
||||
@@ -216,6 +231,10 @@
|
||||
</a>
|
||||
</div>
|
||||
<div class="pt-3">
|
||||
<a class="statistics-team text-dark">
|
||||
<i class="btn-fa fas fa-chart-pie fa-2x px-2" data-toggle="tooltip" data-placement="top"
|
||||
title="Team Statistics"></i>
|
||||
</a>
|
||||
<a class="addresses-team text-dark">
|
||||
<i class="btn-fa fas fa-network-wired fa-2x px-2" data-toggle="tooltip" data-placement="top" title="IP Addresses"></i>
|
||||
</a>
|
||||
|
||||
@@ -3,7 +3,8 @@ import time
|
||||
|
||||
from flask import current_app as app
|
||||
|
||||
from CTFd.utils import get_config
|
||||
from CTFd.constants.themes import DEFAULT_THEME
|
||||
from CTFd.utils import get_app_config, get_config
|
||||
from CTFd.utils.modes import TEAMS_MODE, USERS_MODE
|
||||
|
||||
|
||||
@@ -33,6 +34,12 @@ def ctf_theme():
|
||||
return theme if theme else ""
|
||||
|
||||
|
||||
def ctf_theme_candidates():
|
||||
yield ctf_theme()
|
||||
if bool(get_app_config("THEME_FALLBACK")):
|
||||
yield DEFAULT_THEME
|
||||
|
||||
|
||||
def is_setup():
|
||||
return bool(get_config("setup")) is True
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from sqlalchemy.sql import sqltypes
|
||||
|
||||
from CTFd import __version__ as CTFD_VERSION
|
||||
from CTFd.cache import cache
|
||||
from CTFd.constants.themes import DEFAULT_THEME
|
||||
from CTFd.models import db, get_class_by_tablename
|
||||
from CTFd.plugins import get_plugin_names
|
||||
from CTFd.plugins.migrations import current as plugin_current
|
||||
@@ -67,7 +68,7 @@ def export_ctf():
|
||||
upload_folder = os.path.join(
|
||||
os.path.normpath(app.root_path), app.config.get("UPLOAD_FOLDER")
|
||||
)
|
||||
for root, dirs, files in os.walk(upload_folder):
|
||||
for root, _dirs, files in os.walk(upload_folder):
|
||||
for file in files:
|
||||
parent_dir = os.path.basename(root)
|
||||
backup_zip.write(
|
||||
@@ -353,5 +354,5 @@ def import_ctf(backup, erase=True):
|
||||
cache.clear()
|
||||
|
||||
# Set default theme in case the current instance or the import does not provide it
|
||||
set_config("ctf_theme", "core")
|
||||
set_config("ctf_theme", DEFAULT_THEME)
|
||||
set_config("ctf_version", CTFD_VERSION)
|
||||
|
||||
@@ -35,7 +35,7 @@ class JSONSerializer(object):
|
||||
return result
|
||||
|
||||
def close(self):
|
||||
for path, result in self.buckets.items():
|
||||
for _path, result in self.buckets.items():
|
||||
result = self.wrap(result)
|
||||
|
||||
# Certain databases (MariaDB) store JSON as LONGTEXT.
|
||||
|
||||
@@ -38,7 +38,7 @@ def get_registered_admin_stylesheets():
|
||||
|
||||
|
||||
def override_template(template, html):
|
||||
app.theme_loader.overriden_templates[template] = html
|
||||
app.overridden_templates[template] = html
|
||||
|
||||
|
||||
def get_configurable_plugins():
|
||||
|
||||
@@ -8,7 +8,7 @@ from CTFd.utils.modes import get_model
|
||||
|
||||
|
||||
@cache.memoize(timeout=60)
|
||||
def get_standings(count=None, admin=False, fields=[]):
|
||||
def get_standings(count=None, admin=False, fields=None):
|
||||
"""
|
||||
Get standings as a list of tuples containing account_id, name, and score e.g. [(account_id, team_name, score)].
|
||||
|
||||
@@ -17,6 +17,8 @@ def get_standings(count=None, admin=False, fields=[]):
|
||||
|
||||
Challenges & Awards with a value of zero are filtered out of the calculations to avoid incorrect tie breaks.
|
||||
"""
|
||||
if fields is None:
|
||||
fields = []
|
||||
Model = get_model()
|
||||
|
||||
scores = (
|
||||
@@ -117,7 +119,9 @@ def get_standings(count=None, admin=False, fields=[]):
|
||||
|
||||
|
||||
@cache.memoize(timeout=60)
|
||||
def get_team_standings(count=None, admin=False, fields=[]):
|
||||
def get_team_standings(count=None, admin=False, fields=None):
|
||||
if fields is None:
|
||||
fields = []
|
||||
scores = (
|
||||
db.session.query(
|
||||
Solves.team_id.label("team_id"),
|
||||
@@ -197,7 +201,9 @@ def get_team_standings(count=None, admin=False, fields=[]):
|
||||
|
||||
|
||||
@cache.memoize(timeout=60)
|
||||
def get_user_standings(count=None, admin=False, fields=[]):
|
||||
def get_user_standings(count=None, admin=False, fields=None):
|
||||
if fields is None:
|
||||
fields = []
|
||||
scores = (
|
||||
db.session.query(
|
||||
Solves.user_id.label("user_id"),
|
||||
@@ -245,6 +251,7 @@ def get_user_standings(count=None, admin=False, fields=[]):
|
||||
Users.id.label("user_id"),
|
||||
Users.oauth_id.label("oauth_id"),
|
||||
Users.name.label("name"),
|
||||
Users.team_id.label("team_id"),
|
||||
Users.hidden,
|
||||
Users.banned,
|
||||
sumscores.columns.score,
|
||||
@@ -259,6 +266,7 @@ def get_user_standings(count=None, admin=False, fields=[]):
|
||||
Users.id.label("user_id"),
|
||||
Users.oauth_id.label("oauth_id"),
|
||||
Users.name.label("name"),
|
||||
Users.team_id.label("team_id"),
|
||||
sumscores.columns.score,
|
||||
*fields,
|
||||
)
|
||||
|
||||
@@ -15,38 +15,36 @@ cleaner = Cleaner(
|
||||
style=False,
|
||||
safe_attrs=(
|
||||
safe_attrs
|
||||
| set(
|
||||
[
|
||||
"style",
|
||||
# Allow data attributes from bootstrap elements
|
||||
"data-toggle",
|
||||
"data-target",
|
||||
"data-dismiss",
|
||||
"data-spy",
|
||||
"data-offset",
|
||||
"data-html",
|
||||
"data-placement",
|
||||
"data-parent",
|
||||
"data-title",
|
||||
"data-template",
|
||||
"data-interval",
|
||||
"data-keyboard",
|
||||
"data-pause",
|
||||
"data-ride",
|
||||
"data-wrap",
|
||||
"data-touch",
|
||||
"data-flip",
|
||||
"data-boundary",
|
||||
"data-reference",
|
||||
"data-display",
|
||||
"data-animation",
|
||||
"data-container",
|
||||
"data-delay",
|
||||
"data-selector",
|
||||
"data-content",
|
||||
"data-trigger",
|
||||
]
|
||||
)
|
||||
| {
|
||||
"style",
|
||||
# Allow data attributes from bootstrap elements
|
||||
"data-toggle",
|
||||
"data-target",
|
||||
"data-dismiss",
|
||||
"data-spy",
|
||||
"data-offset",
|
||||
"data-html",
|
||||
"data-placement",
|
||||
"data-parent",
|
||||
"data-title",
|
||||
"data-template",
|
||||
"data-interval",
|
||||
"data-keyboard",
|
||||
"data-pause",
|
||||
"data-ride",
|
||||
"data-wrap",
|
||||
"data-touch",
|
||||
"data-flip",
|
||||
"data-boundary",
|
||||
"data-reference",
|
||||
"data-display",
|
||||
"data-animation",
|
||||
"data-container",
|
||||
"data-delay",
|
||||
"data-selector",
|
||||
"data-content",
|
||||
"data-trigger",
|
||||
}
|
||||
),
|
||||
annoying_tags=False,
|
||||
)
|
||||
|
||||
@@ -182,7 +182,7 @@ def get_user_recent_ips(user_id):
|
||||
.filter(Tracking.user_id == user_id, Tracking.date >= hour_ago)
|
||||
.all()
|
||||
)
|
||||
return set([ip for (ip,) in addrs])
|
||||
return {ip for (ip,) in addrs}
|
||||
|
||||
|
||||
def get_wrong_submissions_per_minute(account_id):
|
||||
|
||||
@@ -14,6 +14,7 @@ from CTFd.constants.config import (
|
||||
RegistrationVisibilityTypes,
|
||||
ScoreVisibilityTypes,
|
||||
)
|
||||
from CTFd.constants.themes import DEFAULT_THEME
|
||||
from CTFd.models import (
|
||||
Admins,
|
||||
Files,
|
||||
@@ -85,7 +86,7 @@ def setup():
|
||||
f = upload_file(file=ctf_small_icon)
|
||||
set_config("ctf_small_icon", f.location)
|
||||
|
||||
theme = request.form.get("ctf_theme", "core")
|
||||
theme = request.form.get("ctf_theme", DEFAULT_THEME)
|
||||
set_config("ctf_theme", theme)
|
||||
theme_color = request.form.get("theme_color")
|
||||
theme_header = get_config("theme_header")
|
||||
@@ -449,8 +450,12 @@ def themes(theme, path):
|
||||
:param path:
|
||||
:return:
|
||||
"""
|
||||
filename = safe_join(app.root_path, "themes", theme, "static", path)
|
||||
if os.path.isfile(filename):
|
||||
return send_file(filename)
|
||||
else:
|
||||
abort(404)
|
||||
for cand_path in (
|
||||
safe_join(app.root_path, "themes", cand_theme, "static", path)
|
||||
# The `theme` value passed in may not be the configured one, e.g. for
|
||||
# admin pages, so we check that first
|
||||
for cand_theme in (theme, *config.ctf_theme_candidates())
|
||||
):
|
||||
if os.path.isfile(cand_path):
|
||||
return send_file(cand_path)
|
||||
abort(404)
|
||||
|
||||
@@ -20,3 +20,5 @@ Faker==4.1.0
|
||||
pipdeptree==0.13.2
|
||||
black==19.10b0
|
||||
pytest-sugar==0.9.4
|
||||
flake8-comprehensions==3.3.1
|
||||
flake8-bugbear==20.11.1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ctfd",
|
||||
"version": "3.1.1",
|
||||
"version": "3.3.0",
|
||||
"description": "CTFd is a Capture The Flag framework focusing on ease of use and customizability. It comes with everything you need to run a CTF and it's easy to customize with plugins and themes.",
|
||||
"main": "index.js",
|
||||
"directories": {
|
||||
|
||||
@@ -11,7 +11,7 @@ def test_generate_user_token():
|
||||
with app.app_context():
|
||||
user = gen_user(app.db)
|
||||
token = generate_user_token(user, expiration=None)
|
||||
token.user_id == user.id
|
||||
assert token.user_id == user.id
|
||||
assert token.expiration > datetime.datetime.utcnow()
|
||||
assert Tokens.query.count() == 1
|
||||
destroy_ctfd(app)
|
||||
|
||||
@@ -154,6 +154,250 @@ def test_api_challenges_get_hidden_admin():
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_api_challenges_get_solve_status():
|
||||
"""Does the challenge list API show the current user's solve status?"""
|
||||
app = create_ctfd()
|
||||
with app.app_context():
|
||||
chal_id = gen_challenge(app.db).id
|
||||
register_user(app)
|
||||
client = login_as_user(app)
|
||||
# First request - unsolved
|
||||
r = client.get("/api/v1/challenges")
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"].pop()
|
||||
assert chal_data["solved_by_me"] is False
|
||||
# Solve and re-request
|
||||
gen_solve(app.db, user_id=2, challenge_id=chal_id)
|
||||
r = client.get("/api/v1/challenges")
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"].pop()
|
||||
assert chal_data["solved_by_me"] is True
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_api_challenges_get_solve_count():
|
||||
"""Does the challenge list API show the solve count?"""
|
||||
# This is checked with public requests against the API after each generated
|
||||
# user makes a solve
|
||||
app = create_ctfd()
|
||||
with app.app_context():
|
||||
set_config("challenge_visibility", "public")
|
||||
chal_id = gen_challenge(app.db).id
|
||||
with app.test_client() as client:
|
||||
_USER_BASE = 2 # First user we create will have this ID
|
||||
_MAX = 3 # arbitrarily selected
|
||||
for i in range(_MAX):
|
||||
# Confirm solve count against `i` first
|
||||
r = client.get("/api/v1/challenges")
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"].pop()
|
||||
assert chal_data["solves"] == i
|
||||
# Generate a new user and solve for the challenge
|
||||
uname = "user{}".format(i)
|
||||
uemail = uname + "@examplectf.com"
|
||||
register_user(app, name=uname, email=uemail)
|
||||
gen_solve(app.db, user_id=_USER_BASE + i, challenge_id=chal_id)
|
||||
# Confirm solve count one final time against `_MAX`
|
||||
r = client.get("/api/v1/challenges")
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"].pop()
|
||||
assert chal_data["solves"] == _MAX
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_api_challenges_get_solve_info_score_visibility():
|
||||
"""Does the challenge list API show solve info if scores are hidden?"""
|
||||
app = create_ctfd()
|
||||
with app.app_context(), app.test_client() as pub_client:
|
||||
set_config("challenge_visibility", "public")
|
||||
|
||||
# Generate a challenge, user and solve to test the API with
|
||||
chal_id = gen_challenge(app.db).id
|
||||
register_user(app)
|
||||
gen_solve(app.db, user_id=2, challenge_id=chal_id)
|
||||
|
||||
# With the public setting any unauthed user should see the solve
|
||||
set_config("score_visibility", "public")
|
||||
r = pub_client.get("/api/v1/challenges")
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"].pop()
|
||||
assert chal_data["solves"] == 1
|
||||
assert chal_data["solved_by_me"] == False
|
||||
|
||||
# With the private setting only an authed user should see the solve
|
||||
set_config("score_visibility", "private")
|
||||
# Test public user
|
||||
r = pub_client.get("/api/v1/challenges")
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"].pop()
|
||||
assert chal_data["solves"] is None
|
||||
assert chal_data["solved_by_me"] is False
|
||||
# Test authed user
|
||||
user_client = login_as_user(app)
|
||||
r = user_client.get("/api/v1/challenges")
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"].pop()
|
||||
assert chal_data["solves"] == 1
|
||||
assert chal_data["solved_by_me"] is True
|
||||
|
||||
# With the admins setting only admins should see the solve
|
||||
set_config("score_visibility", "admins")
|
||||
# Test authed user
|
||||
r = user_client.get("/api/v1/challenges")
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"].pop()
|
||||
assert chal_data["solves"] is None
|
||||
assert chal_data["solved_by_me"] is True
|
||||
# Test admin
|
||||
admin_client = login_as_user(app, "admin", "password")
|
||||
r = admin_client.get("/api/v1/challenges")
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"].pop()
|
||||
assert chal_data["solves"] == 1
|
||||
assert chal_data["solved_by_me"] is False
|
||||
|
||||
# With the hidden setting nobody should see the solve
|
||||
set_config("score_visibility", "hidden")
|
||||
r = admin_client.get("/api/v1/challenges")
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"].pop()
|
||||
assert chal_data["solves"] is None
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_api_challenges_get_solve_info_account_visibility():
|
||||
"""Does the challenge list API show solve info if accounts are hidden?"""
|
||||
app = create_ctfd()
|
||||
with app.app_context(), app.test_client() as pub_client:
|
||||
set_config("challenge_visibility", "public")
|
||||
|
||||
# Generate a challenge, user and solve to test the API with
|
||||
chal_id = gen_challenge(app.db).id
|
||||
register_user(app)
|
||||
gen_solve(app.db, user_id=2, challenge_id=chal_id)
|
||||
|
||||
# With the public setting any unauthed user should see the solve
|
||||
set_config("account_visibility", "public")
|
||||
r = pub_client.get("/api/v1/challenges")
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"].pop()
|
||||
assert chal_data["solves"] == 1
|
||||
assert chal_data["solved_by_me"] is False
|
||||
|
||||
# With the private setting only an authed user should see the solve
|
||||
set_config("account_visibility", "private")
|
||||
# Test public user
|
||||
r = pub_client.get("/api/v1/challenges")
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"].pop()
|
||||
assert chal_data["solves"] is None
|
||||
assert chal_data["solved_by_me"] is False
|
||||
# Test user
|
||||
user_client = login_as_user(app)
|
||||
r = user_client.get("/api/v1/challenges")
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"].pop()
|
||||
assert chal_data["solves"] == 1
|
||||
assert chal_data["solved_by_me"] is True
|
||||
|
||||
# With the admins setting only admins should see the solve
|
||||
set_config("account_visibility", "admins")
|
||||
# Test user
|
||||
r = user_client.get("/api/v1/challenges")
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"].pop()
|
||||
assert chal_data["solves"] is None
|
||||
assert chal_data["solved_by_me"] is True
|
||||
# Test admin user
|
||||
admin_client = login_as_user(app, "admin", "password")
|
||||
r = admin_client.get("/api/v1/challenges")
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"].pop()
|
||||
assert chal_data["solves"] == 1
|
||||
assert chal_data["solved_by_me"] is False
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_api_challenges_get_solve_count_frozen():
|
||||
"""Does the challenge list API count solves made during a freeze?"""
|
||||
app = create_ctfd()
|
||||
with app.app_context(), app.test_client() as client:
|
||||
set_config("challenge_visibility", "public")
|
||||
set_config("freeze", "1507262400")
|
||||
chal_id = gen_challenge(app.db).id
|
||||
|
||||
with freeze_time("2017-10-4"):
|
||||
# Create a user and generate a solve from before the freeze time
|
||||
register_user(app, name="user1", email="user1@examplectf.com")
|
||||
gen_solve(app.db, user_id=2, challenge_id=chal_id)
|
||||
|
||||
# Confirm solve count is now `1`
|
||||
r = client.get("/api/v1/challenges")
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"].pop()
|
||||
assert chal_data["solves"] == 1
|
||||
|
||||
with freeze_time("2017-10-8"):
|
||||
# Create a user and generate a solve from after the freeze time
|
||||
register_user(app, name="user2", email="user2@examplectf.com")
|
||||
gen_solve(app.db, user_id=3, challenge_id=chal_id)
|
||||
|
||||
# Confirm solve count is still `1` despite the new solve
|
||||
r = client.get("/api/v1/challenges")
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"].pop()
|
||||
assert chal_data["solves"] == 1
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_api_challenges_get_solve_count_hidden_user():
|
||||
"""Does the challenge list API show solve counts for hidden users?"""
|
||||
app = create_ctfd()
|
||||
with app.app_context():
|
||||
set_config("challenge_visibility", "public")
|
||||
chal_id = gen_challenge(app.db).id
|
||||
# The admin is expected to be hidden by default
|
||||
gen_solve(app.db, user_id=1, challenge_id=chal_id)
|
||||
with app.test_client() as client:
|
||||
# Confirm solve count is `0` despite the hidden admin having solved
|
||||
r = client.get("/api/v1/challenges")
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"].pop()
|
||||
assert chal_data["solves"] == 0
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_api_challenges_get_solve_count_banned_user():
|
||||
"""Does the challenge list API show solve counts for banned users?"""
|
||||
app = create_ctfd()
|
||||
with app.app_context():
|
||||
set_config("challenge_visibility", "public")
|
||||
chal_id = gen_challenge(app.db).id
|
||||
|
||||
# Create a banned user and generate a solve for the challenge
|
||||
register_user(app)
|
||||
gen_solve(app.db, user_id=2, challenge_id=chal_id)
|
||||
|
||||
# Confirm that the solve is there
|
||||
with app.test_client() as client:
|
||||
r = client.get("/api/v1/challenges")
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"].pop()
|
||||
assert chal_data["solves"] == 1
|
||||
|
||||
# Ban the user
|
||||
Users.query.get(2).banned = True
|
||||
app.db.session.commit()
|
||||
|
||||
with app.test_client() as client:
|
||||
# Confirm solve count is `0` despite the banned user having solved
|
||||
r = client.get("/api/v1/challenges")
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"].pop()
|
||||
assert chal_data["solves"] == 0
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_api_challenges_post_admin():
|
||||
"""Can a user post /api/v1/challenges if admin"""
|
||||
app = create_ctfd()
|
||||
@@ -325,6 +569,264 @@ def test_api_challenge_get_non_existing():
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_api_challenge_get_solve_status():
|
||||
"""Does the challenge detail API show the current user's solve status?"""
|
||||
app = create_ctfd()
|
||||
with app.app_context():
|
||||
chal_id = gen_challenge(app.db).id
|
||||
chal_uri = "/api/v1/challenges/{}".format(chal_id)
|
||||
register_user(app)
|
||||
client = login_as_user(app)
|
||||
# First request - unsolved
|
||||
r = client.get(chal_uri)
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"]
|
||||
assert chal_data["solved_by_me"] is False
|
||||
# Solve and re-request
|
||||
gen_solve(app.db, user_id=2, challenge_id=chal_id)
|
||||
r = client.get(chal_uri)
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"]
|
||||
assert chal_data["solved_by_me"] is True
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_api_challenge_get_solve_info_score_visibility():
|
||||
"""Does the challenge detail API show solve info if scores are hidden?"""
|
||||
app = create_ctfd()
|
||||
with app.app_context(), app.test_client() as pub_client:
|
||||
set_config("challenge_visibility", "public")
|
||||
# Generate a challenge, user and solve to test the API with
|
||||
chal_id = gen_challenge(app.db).id
|
||||
chal_uri = "/api/v1/challenges/{}".format(chal_id)
|
||||
register_user(app)
|
||||
gen_solve(app.db, user_id=2, challenge_id=chal_id)
|
||||
|
||||
# With the public setting any unauthed user should see the solve
|
||||
set_config("score_visibility", "public")
|
||||
r = pub_client.get(chal_uri)
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"]
|
||||
assert chal_data["solves"] == 1
|
||||
assert chal_data["solved_by_me"] is False
|
||||
|
||||
# With the private setting only an authed user should see the solve
|
||||
set_config("score_visibility", "private")
|
||||
# Test public user
|
||||
r = pub_client.get(chal_uri)
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"]
|
||||
assert chal_data["solves"] is None
|
||||
assert chal_data["solved_by_me"] is False
|
||||
# Test user
|
||||
user_client = login_as_user(app)
|
||||
r = user_client.get(chal_uri)
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"]
|
||||
assert chal_data["solves"] == 1
|
||||
assert chal_data["solved_by_me"] is True
|
||||
|
||||
# With the admins setting only admins should see the solve
|
||||
set_config("score_visibility", "admins")
|
||||
# Test user
|
||||
r = user_client.get(chal_uri)
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"]
|
||||
assert chal_data["solves"] is None
|
||||
assert chal_data["solved_by_me"] is True
|
||||
# Test admin user
|
||||
admin_client = login_as_user(app, "admin", "password")
|
||||
r = admin_client.get(chal_uri)
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"]
|
||||
assert chal_data["solves"] == 1
|
||||
assert chal_data["solved_by_me"] is False
|
||||
|
||||
# With the hidden setting nobody should see the solve
|
||||
set_config("score_visibility", "hidden")
|
||||
r = admin_client.get(chal_uri)
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"]
|
||||
assert chal_data["solves"] is None
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_api_challenge_get_solve_info_account_visibility():
|
||||
"""Does the challenge detail API show solve info if accounts are hidden?"""
|
||||
app = create_ctfd()
|
||||
with app.app_context(), app.test_client() as pub_client:
|
||||
set_config("challenge_visibility", "public")
|
||||
# Generate a challenge, user and solve to test the API with
|
||||
chal_id = gen_challenge(app.db).id
|
||||
chal_uri = "/api/v1/challenges/{}".format(chal_id)
|
||||
register_user(app)
|
||||
gen_solve(app.db, user_id=2, challenge_id=chal_id)
|
||||
|
||||
# With the public setting any unauthed user should see the solve
|
||||
set_config("account_visibility", "public")
|
||||
r = pub_client.get(chal_uri)
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"]
|
||||
assert chal_data["solves"] == 1
|
||||
assert chal_data["solved_by_me"] is False
|
||||
|
||||
# With the private setting only an authed user should see the solve
|
||||
set_config("account_visibility", "private")
|
||||
# Test public user
|
||||
r = pub_client.get(chal_uri)
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"]
|
||||
assert chal_data["solves"] is None
|
||||
assert chal_data["solved_by_me"] is False
|
||||
# Test user
|
||||
user_client = login_as_user(app)
|
||||
r = user_client.get(chal_uri)
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"]
|
||||
assert chal_data["solves"] == 1
|
||||
assert chal_data["solved_by_me"] is True
|
||||
|
||||
# With the admins setting only admins should see the solve
|
||||
set_config("account_visibility", "admins")
|
||||
# Test user
|
||||
r = user_client.get(chal_uri)
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"]
|
||||
assert chal_data["solves"] is None
|
||||
assert chal_data["solved_by_me"] is True
|
||||
# Test admin user
|
||||
admin_client = login_as_user(app, "admin", "password")
|
||||
r = admin_client.get(chal_uri)
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"]
|
||||
assert chal_data["solves"] == 1
|
||||
assert chal_data["solved_by_me"] is False
|
||||
|
||||
# With the hidden setting admins can still see the solve
|
||||
# because the challenge detail endpoint doesn't have an admin specific view
|
||||
set_config("account_visibility", "hidden")
|
||||
r = admin_client.get(chal_uri)
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"]
|
||||
assert chal_data["solves"] == 1
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_api_challenge_get_solve_count():
|
||||
"""Does the challenge detail API show the solve count?"""
|
||||
# This is checked with public requests against the API after each generated
|
||||
# user makes a solve
|
||||
app = create_ctfd()
|
||||
with app.app_context():
|
||||
set_config("challenge_visibility", "public")
|
||||
chal_id = gen_challenge(app.db).id
|
||||
chal_uri = "/api/v1/challenges/{}".format(chal_id)
|
||||
with app.test_client() as client:
|
||||
_USER_BASE = 2 # First user we create will have this ID
|
||||
_MAX = 3 # arbitrarily selected
|
||||
for i in range(_MAX):
|
||||
# Confirm solve count against `i` first
|
||||
r = client.get(chal_uri)
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"]
|
||||
assert chal_data["solves"] == i
|
||||
# Generate a new user and solve for the challenge
|
||||
uname = "user{}".format(i)
|
||||
uemail = uname + "@examplectf.com"
|
||||
register_user(app, name=uname, email=uemail)
|
||||
gen_solve(app.db, user_id=_USER_BASE + i, challenge_id=chal_id)
|
||||
# Confirm solve count one final time against `_MAX`
|
||||
r = client.get(chal_uri)
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"]
|
||||
assert chal_data["solves"] == _MAX
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_api_challenge_get_solve_count_frozen():
|
||||
"""Does the challenge detail API count solves made during a freeze?"""
|
||||
app = create_ctfd()
|
||||
with app.app_context(), app.test_client() as client:
|
||||
set_config("challenge_visibility", "public")
|
||||
# Friday, October 6, 2017 4:00:00 AM
|
||||
set_config("freeze", "1507262400")
|
||||
chal_id = gen_challenge(app.db).id
|
||||
chal_uri = "/api/v1/challenges/{}".format(chal_id)
|
||||
|
||||
with freeze_time("2017-10-4"):
|
||||
# Create a user and generate a solve from before the freeze time
|
||||
register_user(app, name="user1", email="user1@examplectf.com")
|
||||
gen_solve(app.db, user_id=2, challenge_id=chal_id)
|
||||
|
||||
# Confirm solve count is now `1`
|
||||
r = client.get(chal_uri)
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"]
|
||||
assert chal_data["solves"] == 1
|
||||
|
||||
with freeze_time("2017-10-8"):
|
||||
# Create a user and generate a solve from after the freeze time
|
||||
register_user(app, name="user2", email="user2@examplectf.com")
|
||||
gen_solve(app.db, user_id=3, challenge_id=chal_id)
|
||||
|
||||
# Confirm solve count is still `1` despite the new solve
|
||||
r = client.get(chal_uri)
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"]
|
||||
assert chal_data["solves"] == 1
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_api_challenge_get_solve_count_hidden_user():
|
||||
"""Does the challenge detail API show solve counts for hidden users?"""
|
||||
app = create_ctfd()
|
||||
with app.app_context():
|
||||
set_config("challenge_visibility", "public")
|
||||
chal_id = gen_challenge(app.db).id
|
||||
chal_uri = "/api/v1/challenges/{}".format(chal_id)
|
||||
# The admin is expected to be hidden by default
|
||||
gen_solve(app.db, user_id=1, challenge_id=chal_id)
|
||||
with app.test_client() as client:
|
||||
# Confirm solve count is `0` despite the hidden admin having solved
|
||||
r = client.get(chal_uri)
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"]
|
||||
assert chal_data["solves"] == 0
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_api_challenge_get_solve_count_banned_user():
|
||||
"""Does the challenge detail API show solve counts for banned users?"""
|
||||
app = create_ctfd()
|
||||
with app.app_context():
|
||||
set_config("challenge_visibility", "public")
|
||||
chal_id = gen_challenge(app.db).id
|
||||
chal_uri = "/api/v1/challenges/{}".format(chal_id)
|
||||
|
||||
# Create a user and generate a solve for the challenge
|
||||
register_user(app)
|
||||
gen_solve(app.db, user_id=2, challenge_id=chal_id)
|
||||
|
||||
# Confirm that the solve is there
|
||||
with app.test_client() as client:
|
||||
r = client.get(chal_uri)
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"]
|
||||
assert chal_data["solves"] == 1
|
||||
|
||||
# Ban the user
|
||||
Users.query.get(2).banned = True
|
||||
app.db.session.commit()
|
||||
|
||||
# Confirm solve count is `0` despite the banned user having solved
|
||||
with app.test_client() as client:
|
||||
r = client.get(chal_uri)
|
||||
assert r.status_code == 200
|
||||
chal_data = r.get_json()["data"]
|
||||
assert chal_data["solves"] == 0
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_api_challenge_patch_non_admin():
|
||||
"""Can a user patch /api/v1/challenges/<challenge_id> if not admin"""
|
||||
app = create_ctfd()
|
||||
@@ -439,7 +941,7 @@ def test_api_challenge_attempt_post_private():
|
||||
challenge_id = gen_challenge(app.db).id
|
||||
gen_flag(app.db, challenge_id)
|
||||
with login_as_user(app) as client:
|
||||
for i in range(10):
|
||||
for _ in range(10):
|
||||
gen_fail(app.db, user_id=2, challenge_id=challenge_id)
|
||||
r = client.post(
|
||||
"/api/v1/challenges/attempt",
|
||||
@@ -480,7 +982,7 @@ def test_api_challenge_attempt_post_private():
|
||||
challenge_id = gen_challenge(app.db).id
|
||||
gen_flag(app.db, challenge_id)
|
||||
with login_as_user(app) as client:
|
||||
for i in range(10):
|
||||
for _ in range(10):
|
||||
gen_fail(app.db, user_id=2, team_id=team_id, challenge_id=challenge_id)
|
||||
r = client.post(
|
||||
"/api/v1/challenges/attempt",
|
||||
|
||||
@@ -68,9 +68,10 @@ def test_api_files_post_admin():
|
||||
r = client.post(
|
||||
"/api/v1/files",
|
||||
content_type="multipart/form-data",
|
||||
data=dict(
|
||||
file=(BytesIO(b"test file content"), "test.txt"), nonce=nonce
|
||||
),
|
||||
data={
|
||||
"file": (BytesIO(b"test file content"), "test.txt"),
|
||||
"nonce": nonce,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
f = Files.query.filter_by(id=1).first()
|
||||
|
||||
@@ -49,13 +49,13 @@ def test_api_tag_list_get():
|
||||
r = client.get("/api/v1/tokens", json="")
|
||||
assert r.status_code == 200
|
||||
resp = r.get_json()
|
||||
len(resp["data"]) == 1
|
||||
assert len(resp["data"]) == 1
|
||||
|
||||
with login_as_user(app, name="user2") as client:
|
||||
r = client.get("/api/v1/tokens", json="")
|
||||
assert r.status_code == 200
|
||||
resp = r.get_json()
|
||||
len(resp["data"]) == 2
|
||||
assert len(resp["data"]) == 2
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
|
||||
@@ -293,7 +293,7 @@ def test_dynamic_challenge_value_isnt_affected_by_hidden_users():
|
||||
assert resp["status"] == "correct"
|
||||
|
||||
# Make solves as hidden users. Also should not affect value
|
||||
for i, team_id in enumerate(range(2, 26)):
|
||||
for _, team_id in enumerate(range(2, 26)):
|
||||
name = "user{}".format(team_id)
|
||||
email = "user{}@examplectf.com".format(team_id)
|
||||
# We need to bypass rate-limiting so gen_user instead of register_user
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from flask import request
|
||||
import os
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
from flask import render_template, render_template_string, request
|
||||
from jinja2.exceptions import TemplateNotFound
|
||||
from jinja2.sandbox import SecurityError
|
||||
from werkzeug.test import Client
|
||||
|
||||
from CTFd.utils import get_config
|
||||
from CTFd.config import TestingConfig
|
||||
from CTFd.utils import get_config, set_config
|
||||
from tests.helpers import create_ctfd, destroy_ctfd, gen_user, login_as_user
|
||||
|
||||
|
||||
@@ -142,3 +148,76 @@ def test_that_request_path_hijacking_works_properly():
|
||||
with test_app.test_request_context("/challenges"):
|
||||
assert request.path == "/challenges"
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_theme_fallback_config():
|
||||
"""Test that the `THEME_FALLBACK` config properly falls themes back to the core theme"""
|
||||
app = create_ctfd()
|
||||
# Make an empty theme
|
||||
try:
|
||||
os.mkdir(os.path.join(app.root_path, "themes", "foo"))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Without theme fallback, missing themes should disappear
|
||||
with app.app_context():
|
||||
set_config("ctf_theme", "foo")
|
||||
assert app.config["THEME_FALLBACK"] == False
|
||||
with app.test_client() as client:
|
||||
try:
|
||||
r = client.get("/")
|
||||
except TemplateNotFound:
|
||||
pass
|
||||
try:
|
||||
r = client.get("/themes/foo/static/js/pages/main.dev.js")
|
||||
except TemplateNotFound:
|
||||
pass
|
||||
destroy_ctfd(app)
|
||||
|
||||
class ThemeFallbackConfig(TestingConfig):
|
||||
THEME_FALLBACK = True
|
||||
|
||||
app = create_ctfd(config=ThemeFallbackConfig)
|
||||
with app.app_context():
|
||||
set_config("ctf_theme", "foo")
|
||||
assert app.config["THEME_FALLBACK"] == True
|
||||
with app.test_client() as client:
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
r = client.get("/themes/foo/static/js/pages/main.dev.js")
|
||||
assert r.status_code == 200
|
||||
destroy_ctfd(app)
|
||||
|
||||
# Remove empty theme
|
||||
os.rmdir(os.path.join(app.root_path, "themes", "foo"))
|
||||
|
||||
|
||||
def test_theme_template_loading_by_prefix():
|
||||
"""Test that we can load theme files by their folder prefix"""
|
||||
app = create_ctfd()
|
||||
with app.test_request_context():
|
||||
tpl1 = render_template_string("{% extends 'core/page.html' %}", content="test")
|
||||
tpl2 = render_template("page.html", content="test")
|
||||
assert tpl1 == tpl2
|
||||
|
||||
|
||||
def test_theme_template_disallow_loading_admin_templates():
|
||||
"""Test that admin files in a theme will not be loaded"""
|
||||
app = create_ctfd()
|
||||
with app.app_context():
|
||||
try:
|
||||
# Make an empty malicious theme
|
||||
filename = os.path.join(
|
||||
app.root_path, "themes", "foo", "admin", "malicious.html"
|
||||
)
|
||||
os.makedirs(os.path.dirname(filename), exist_ok=True)
|
||||
with open(filename, "w") as f:
|
||||
f.write("malicious")
|
||||
|
||||
with pytest.raises(TemplateNotFound):
|
||||
render_template_string("{% include 'admin/malicious.html' %}")
|
||||
finally:
|
||||
# Remove empty theme
|
||||
shutil.rmtree(
|
||||
os.path.join(app.root_path, "themes", "foo"), ignore_errors=True
|
||||
)
|
||||
|
||||
@@ -252,7 +252,7 @@ def test_challenges_with_max_attempts():
|
||||
app.db.session.commit()
|
||||
|
||||
gen_flag(app.db, challenge_id=chal.id, content=u"flag")
|
||||
for x in range(3):
|
||||
for _ in range(3):
|
||||
data = {"submission": "notflag", "challenge_id": chal_id}
|
||||
r = client.post("/api/v1/challenges/attempt", json=data)
|
||||
|
||||
@@ -282,7 +282,7 @@ def test_challenge_kpm_limit():
|
||||
chal_id = chal.id
|
||||
|
||||
gen_flag(app.db, challenge_id=chal.id, content=u"flag")
|
||||
for x in range(11):
|
||||
for _ in range(11):
|
||||
with client.session_transaction():
|
||||
data = {"submission": "notflag", "challenge_id": chal_id}
|
||||
r = client.post("/api/v1/challenges/attempt", json=data)
|
||||
|
||||
@@ -33,7 +33,7 @@ def test_user_set_profile():
|
||||
|
||||
r = client.get("/settings")
|
||||
resp = r.get_data(as_text=True)
|
||||
for k, v in data.items():
|
||||
for _k, v in data.items():
|
||||
assert v in resp
|
||||
|
||||
data = {
|
||||
|
||||
@@ -14,11 +14,11 @@ def test_ratelimit_on_auth():
|
||||
"password": "wrong_password",
|
||||
"nonce": sess.get("nonce"),
|
||||
}
|
||||
for x in range(10):
|
||||
for _ in range(10):
|
||||
r = client.post("/login", data=data)
|
||||
assert r.status_code == 200
|
||||
|
||||
for x in range(5):
|
||||
for _ in range(5):
|
||||
r = client.post("/login", data=data)
|
||||
assert r.status_code == 429
|
||||
destroy_ctfd(app)
|
||||
|
||||
Reference in New Issue
Block a user