diff --git a/CHANGELOG.md b/CHANGELOG.md index 253682ae..df026db1 100644 --- a/CHANGELOG.md +++ b/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 `
` 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 diff --git a/CTFd/__init__.py b/CTFd/__init__.py index 5146449f..4388440f 100644 --- a/CTFd/__init__.py +++ b/CTFd/__init__.py @@ -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) diff --git a/CTFd/admin/__init__.py b/CTFd/admin/__init__.py index 5e38d895..0a5410a2 100644 --- a/CTFd/admin/__init__.py +++ b/CTFd/admin/__init__.py @@ -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")) diff --git a/CTFd/admin/scoreboard.py b/CTFd/admin/scoreboard.py index b1c7374c..c876814d 100644 --- a/CTFd/admin/scoreboard.py +++ b/CTFd/admin/scoreboard.py @@ -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 + ) diff --git a/CTFd/admin/statistics.py b/CTFd/admin/statistics.py index ebbc2f72..cf629fd7 100644 --- a/CTFd/admin/statistics.py +++ b/CTFd/admin/statistics.py @@ -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 diff --git a/CTFd/api/v1/challenges.py b/CTFd/api/v1/challenges.py index de450955..f27b7a92 100644 --- a/CTFd/api/v1/challenges.py +++ b/CTFd/api/v1/challenges.py @@ -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 diff --git a/CTFd/api/v1/helpers/schemas.py b/CTFd/api/v1/helpers/schemas.py index 6b443f5e..51345c19 100644 --- a/CTFd/api/v1/helpers/schemas.py +++ b/CTFd/api/v1/helpers/schemas.py @@ -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: diff --git a/CTFd/api/v1/scoreboard.py b/CTFd/api/v1/scoreboard.py index b0f9ce2e..02620b8e 100644 --- a/CTFd/api/v1/scoreboard.py +++ b/CTFd/api/v1/scoreboard.py @@ -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, diff --git a/CTFd/api/v1/users.py b/CTFd/api/v1/users.py index a1c6dd6e..bd1d2ca9 100644 --- a/CTFd/api/v1/users.py +++ b/CTFd/api/v1/users.py @@ -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", diff --git a/CTFd/config.ini b/CTFd/config.ini index e74f6bcd..397257cb 100644 --- a/CTFd/config.ini +++ b/CTFd/config.ini @@ -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 = diff --git a/CTFd/config.py b/CTFd/config.py index f597e577..a06b9da0 100644 --- a/CTFd/config.py +++ b/CTFd/config.py @@ -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) diff --git a/CTFd/constants/themes.py b/CTFd/constants/themes.py new file mode 100644 index 00000000..46c92858 --- /dev/null +++ b/CTFd/constants/themes.py @@ -0,0 +1,2 @@ +ADMIN_THEME = "admin" +DEFAULT_THEME = "core" diff --git a/CTFd/errors.py b/CTFd/errors.py index 3676aad8..d2c3c88f 100644 --- a/CTFd/errors.py +++ b/CTFd/errors.py @@ -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() diff --git a/CTFd/forms/setup.py b/CTFd/forms/setup.py index d7f5ff24..83cb7550 100644 --- a/CTFd/forms/setup.py +++ b/CTFd/forms/setup.py @@ -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( diff --git a/CTFd/plugins/challenges/__init__.py b/CTFd/plugins/challenges/__init__.py index 9380a59d..8c7c4f2b 100644 --- a/CTFd/plugins/challenges/__init__.py +++ b/CTFd/plugins/challenges/__init__.py @@ -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 diff --git a/CTFd/schemas/users.py b/CTFd/schemas/users.py index c42a6a8e..ff204366 100644 --- a/CTFd/schemas/users.py +++ b/CTFd/schemas/users.py @@ -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", ], } diff --git a/CTFd/themes/admin/assets/js/components/teams/UserAddForm.vue b/CTFd/themes/admin/assets/js/components/teams/UserAddForm.vue new file mode 100644 index 00000000..a43417f8 --- /dev/null +++ b/CTFd/themes/admin/assets/js/components/teams/UserAddForm.vue @@ -0,0 +1,229 @@ + +++ + + + + diff --git a/CTFd/themes/admin/assets/js/pages/scoreboard.js b/CTFd/themes/admin/assets/js/pages/scoreboard.js index 01c8ab48..58fa7b54 100644 --- a/CTFd/themes/admin/assets/js/pages/scoreboard.js +++ b/CTFd/themes/admin/assets/js/pages/scoreboard.js @@ -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); } }); } diff --git a/CTFd/themes/admin/assets/js/pages/team.js b/CTFd/themes/admin/assets/js/pages/team.js index 02a33589..5a9a6067 100644 --- a/CTFd/themes/admin/assets/js/pages/team.js +++ b/CTFd/themes/admin/assets/js/pages/team.js @@ -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}?+ + +++ + {{ user.name }} + × + +++++ + No users found + +++
+- + {{ user.name }} + + already in a team + +
++ ++
All of their challenges solves, attempts, awards, and unlocked hints will also be deleted!".format( + body: "Are you sure you want to remove {0} from {1}?
All of their challenge solves, attempts, awards, and unlocked hints will also be deleted!".format( "" + htmlEntities(member_name) + "", "" + htmlEntities(window.TEAM_NAME) + "" ), @@ -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); diff --git a/CTFd/themes/admin/static/js/components.dev.js b/CTFd/themes/admin/static/js/components.dev.js index 53b60842..430541a8 100644 --- a/CTFd/themes/admin/static/js/components.dev.js +++ b/CTFd/themes/admin/static/js/components.dev.js @@ -516,6 +516,42 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _nod /***/ }), +/***/ "./CTFd/themes/admin/assets/js/components/teams/UserAddForm.vue": +/*!**********************************************************************!*\ + !*** ./CTFd/themes/admin/assets/js/components/teams/UserAddForm.vue ***! + \**********************************************************************/ +/*! no static exports found */ +/***/ (function(module, __webpack_exports__, __webpack_require__) { + +; +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _UserAddForm_vue_vue_type_template_id_84c1b916_scoped_true___WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./UserAddForm.vue?vue&type=template&id=84c1b916&scoped=true& */ \"./CTFd/themes/admin/assets/js/components/teams/UserAddForm.vue?vue&type=template&id=84c1b916&scoped=true&\");\n/* harmony import */ var _UserAddForm_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./UserAddForm.vue?vue&type=script&lang=js& */ \"./CTFd/themes/admin/assets/js/components/teams/UserAddForm.vue?vue&type=script&lang=js&\");\n/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _UserAddForm_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_1__) if(__WEBPACK_IMPORT_KEY__ !== 'default') (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _UserAddForm_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_1__[key]; }) }(__WEBPACK_IMPORT_KEY__));\n/* harmony import */ var _node_modules_vue_loader_lib_runtime_componentNormalizer_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ../../../../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js */ \"./node_modules/vue-loader/lib/runtime/componentNormalizer.js\");\n\n\n\n\n\n/* normalize component */\n\nvar component = Object(_node_modules_vue_loader_lib_runtime_componentNormalizer_js__WEBPACK_IMPORTED_MODULE_2__[\"default\"])(\n _UserAddForm_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_1__[\"default\"],\n _UserAddForm_vue_vue_type_template_id_84c1b916_scoped_true___WEBPACK_IMPORTED_MODULE_0__[\"render\"],\n _UserAddForm_vue_vue_type_template_id_84c1b916_scoped_true___WEBPACK_IMPORTED_MODULE_0__[\"staticRenderFns\"],\n false,\n null,\n \"84c1b916\",\n null\n \n)\n\n/* hot reload */\nif (false) { var api; }\ncomponent.options.__file = \"CTFd/themes/admin/assets/js/components/teams/UserAddForm.vue\"\n/* harmony default export */ __webpack_exports__[\"default\"] = (component.exports);\n\n//# sourceURL=webpack:///./CTFd/themes/admin/assets/js/components/teams/UserAddForm.vue?"); + +/***/ }), + +/***/ "./CTFd/themes/admin/assets/js/components/teams/UserAddForm.vue?vue&type=script&lang=js&": +/*!***********************************************************************************************!*\ + !*** ./CTFd/themes/admin/assets/js/components/teams/UserAddForm.vue?vue&type=script&lang=js& ***! + \***********************************************************************************************/ +/*! no static exports found */ +/***/ (function(module, __webpack_exports__, __webpack_require__) { + +; +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _node_modules_babel_loader_lib_index_js_ref_0_node_modules_vue_loader_lib_index_js_vue_loader_options_UserAddForm_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! -!../../../../../../../node_modules/babel-loader/lib??ref--0!../../../../../../../node_modules/vue-loader/lib??vue-loader-options!./UserAddForm.vue?vue&type=script&lang=js& */ \"./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/teams/UserAddForm.vue?vue&type=script&lang=js&\");\n/* harmony import */ var _node_modules_babel_loader_lib_index_js_ref_0_node_modules_vue_loader_lib_index_js_vue_loader_options_UserAddForm_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_node_modules_babel_loader_lib_index_js_ref_0_node_modules_vue_loader_lib_index_js_vue_loader_options_UserAddForm_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_0__);\n/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _node_modules_babel_loader_lib_index_js_ref_0_node_modules_vue_loader_lib_index_js_vue_loader_options_UserAddForm_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_0__) if(__WEBPACK_IMPORT_KEY__ !== 'default') (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _node_modules_babel_loader_lib_index_js_ref_0_node_modules_vue_loader_lib_index_js_vue_loader_options_UserAddForm_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_0__[key]; }) }(__WEBPACK_IMPORT_KEY__));\n /* harmony default export */ __webpack_exports__[\"default\"] = (_node_modules_babel_loader_lib_index_js_ref_0_node_modules_vue_loader_lib_index_js_vue_loader_options_UserAddForm_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_0___default.a); \n\n//# sourceURL=webpack:///./CTFd/themes/admin/assets/js/components/teams/UserAddForm.vue?"); + +/***/ }), + +/***/ "./CTFd/themes/admin/assets/js/components/teams/UserAddForm.vue?vue&type=template&id=84c1b916&scoped=true&": +/*!*****************************************************************************************************************!*\ + !*** ./CTFd/themes/admin/assets/js/components/teams/UserAddForm.vue?vue&type=template&id=84c1b916&scoped=true& ***! + \*****************************************************************************************************************/ +/*! exports provided: render, staticRenderFns */ +/***/ (function(module, __webpack_exports__, __webpack_require__) { + +; +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _node_modules_vue_loader_lib_loaders_templateLoader_js_vue_loader_options_node_modules_vue_loader_lib_index_js_vue_loader_options_UserAddForm_vue_vue_type_template_id_84c1b916_scoped_true___WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! -!../../../../../../../node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!../../../../../../../node_modules/vue-loader/lib??vue-loader-options!./UserAddForm.vue?vue&type=template&id=84c1b916&scoped=true& */ \"./node_modules/vue-loader/lib/loaders/templateLoader.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/teams/UserAddForm.vue?vue&type=template&id=84c1b916&scoped=true&\");\n/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, \"render\", function() { return _node_modules_vue_loader_lib_loaders_templateLoader_js_vue_loader_options_node_modules_vue_loader_lib_index_js_vue_loader_options_UserAddForm_vue_vue_type_template_id_84c1b916_scoped_true___WEBPACK_IMPORTED_MODULE_0__[\"render\"]; });\n\n/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, \"staticRenderFns\", function() { return _node_modules_vue_loader_lib_loaders_templateLoader_js_vue_loader_options_node_modules_vue_loader_lib_index_js_vue_loader_options_UserAddForm_vue_vue_type_template_id_84c1b916_scoped_true___WEBPACK_IMPORTED_MODULE_0__[\"staticRenderFns\"]; });\n\n\n\n//# sourceURL=webpack:///./CTFd/themes/admin/assets/js/components/teams/UserAddForm.vue?"); + +/***/ }), + /***/ "./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/comments/CommentBox.vue?vue&type=script&lang=js&": /*!*******************************************************************************************************************************************************************************************!*\ !*** ./node_modules/babel-loader/lib??ref--0!./node_modules/vue-loader/lib??vue-loader-options!./CTFd/themes/admin/assets/js/components/comments/CommentBox.vue?vue&type=script&lang=js& ***! @@ -684,6 +720,18 @@ eval("\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\n /***/ }), +/***/ "./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/teams/UserAddForm.vue?vue&type=script&lang=js&": +/*!*****************************************************************************************************************************************************************************************!*\ + !*** ./node_modules/babel-loader/lib??ref--0!./node_modules/vue-loader/lib??vue-loader-options!./CTFd/themes/admin/assets/js/components/teams/UserAddForm.vue?vue&type=script&lang=js& ***! + \*****************************************************************************************************************************************************************************************/ +/*! no static exports found */ +/***/ (function(module, exports, __webpack_require__) { + +; +eval("\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports[\"default\"] = void 0;\n\nvar _CTFd = _interopRequireDefault(__webpack_require__(/*! core/CTFd */ \"./CTFd/themes/core/assets/js/CTFd.js\"));\n\nvar _ezq = __webpack_require__(/*! core/ezq */ \"./CTFd/themes/core/assets/js/ezq.js\");\n\nvar _utils = __webpack_require__(/*! core/utils */ \"./CTFd/themes/core/assets/js/utils.js\");\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { \"default\": obj }; }\n\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\nvar _default = {\n name: \"UserAddForm\",\n props: {\n team_id: Number\n },\n data: function data() {\n return {\n searchedName: \"\",\n awaitingSearch: false,\n emptyResults: false,\n userResults: [],\n selectedResultIdx: 0,\n selectedUsers: []\n };\n },\n methods: {\n searchUsers: function searchUsers() {\n var _this = this;\n\n this.selectedResultIdx = 0;\n\n if (this.searchedName == \"\") {\n this.userResults = [];\n return;\n }\n\n _CTFd[\"default\"].fetch(\"/api/v1/users?view=admin&field=name&q=\".concat(this.searchedName), {\n method: \"GET\",\n credentials: \"same-origin\",\n headers: {\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\"\n }\n }).then(function (response) {\n return response.json();\n }).then(function (response) {\n if (response.success) {\n _this.userResults = response.data.slice(0, 10);\n }\n });\n },\n moveCursor: function moveCursor(dir) {\n switch (dir) {\n case \"up\":\n if (this.selectedResultIdx) {\n this.selectedResultIdx -= 1;\n }\n\n break;\n\n case \"down\":\n if (this.selectedResultIdx < this.userResults.length - 1) {\n this.selectedResultIdx += 1;\n }\n\n break;\n }\n },\n selectUser: function selectUser(idx) {\n if (idx === undefined) {\n idx = this.selectedResultIdx;\n }\n\n var user = this.userResults[idx]; // Avoid duplicates\n\n var found = this.selectedUsers.some(function (searchUser) {\n return searchUser.id === user.id;\n });\n\n if (found === false) {\n this.selectedUsers.push(user);\n }\n\n this.userResults = [];\n this.searchedName = \"\";\n },\n removeSelectedUser: function removeSelectedUser(user_id) {\n this.selectedUsers = this.selectedUsers.filter(function (user) {\n return user.id !== user_id;\n });\n },\n handleAddUsersRequest: function handleAddUsersRequest() {\n var _this2 = this;\n\n var reqs = [];\n this.selectedUsers.forEach(function (user) {\n var body = {\n user_id: user.id\n };\n reqs.push(_CTFd[\"default\"].fetch(\"/api/v1/teams/\".concat(_this2.$props.team_id, \"/members\"), {\n method: \"POST\",\n credentials: \"same-origin\",\n headers: {\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\"\n },\n body: JSON.stringify(body)\n }));\n });\n return Promise.all(reqs);\n },\n handleRemoveUsersFromTeams: function handleRemoveUsersFromTeams() {\n var reqs = [];\n this.selectedUsers.forEach(function (user) {\n var body = {\n user_id: user.id\n };\n reqs.push(_CTFd[\"default\"].fetch(\"/api/v1/teams/\".concat(user.team_id, \"/members\"), {\n method: \"DELETE\",\n body: JSON.stringify(body)\n }));\n });\n return Promise.all(reqs);\n },\n addUsers: function addUsers() {\n var _this3 = this;\n\n var usersInTeams = [];\n this.selectedUsers.forEach(function (user) {\n if (user.team_id) {\n usersInTeams.push(user.name);\n }\n });\n\n if (usersInTeams.length) {\n var users = (0, _utils.htmlEntities)(usersInTeams.join(\", \"));\n (0, _ezq.ezQuery)({\n title: \"Confirm Team Removal\",\n body: \"The following users are currently in teams:
\".concat(users, \"
Are you sure you want to remove them from their current teams and add them to this one?
All of their challenge solves, attempts, awards, and unlocked hints will also be deleted!\"),\n success: function success() {\n _this3.handleRemoveUsersFromTeams().then(function (_resps) {\n _this3.handleAddUsersRequest().then(function (_resps) {\n window.location.reload();\n });\n });\n }\n });\n } else {\n this.handleAddUsersRequest().then(function (_resps) {\n window.location.reload();\n });\n }\n }\n },\n watch: {\n searchedName: function searchedName(val) {\n var _this4 = this;\n\n if (this.awaitingSearch === false) {\n // 1 second delay after typing\n setTimeout(function () {\n _this4.searchUsers();\n\n _this4.awaitingSearch = false;\n }, 1000);\n }\n\n this.awaitingSearch = true;\n }\n }\n};\nexports[\"default\"] = _default;\n\n//# sourceURL=webpack:///./CTFd/themes/admin/assets/js/components/teams/UserAddForm.vue?./node_modules/babel-loader/lib??ref--0!./node_modules/vue-loader/lib??vue-loader-options"); + +/***/ }), + /***/ "./node_modules/css-loader/dist/cjs.js!./node_modules/vue-loader/lib/loaders/stylePostLoader.js!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/comments/CommentBox.vue?vue&type=style&index=0&id=1fd2c08a&scoped=true&lang=css&": /*!**********************************************************************************************************************************************************************************************************************************************************************************!*\ !*** ./node_modules/css-loader/dist/cjs.js!./node_modules/vue-loader/lib/loaders/stylePostLoader.js!./node_modules/vue-loader/lib??vue-loader-options!./CTFd/themes/admin/assets/js/components/comments/CommentBox.vue?vue&type=style&index=0&id=1fd2c08a&scoped=true&lang=css& ***! @@ -863,6 +911,18 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) * /***/ }), +/***/ "./node_modules/vue-loader/lib/loaders/templateLoader.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/teams/UserAddForm.vue?vue&type=template&id=84c1b916&scoped=true&": +/*!***********************************************************************************************************************************************************************************************************************************************!*\ + !*** ./node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!./node_modules/vue-loader/lib??vue-loader-options!./CTFd/themes/admin/assets/js/components/teams/UserAddForm.vue?vue&type=template&id=84c1b916&scoped=true& ***! + \***********************************************************************************************************************************************************************************************************************************************/ +/*! exports provided: render, staticRenderFns */ +/***/ (function(module, __webpack_exports__, __webpack_require__) { + +; +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"render\", function() { return render; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"staticRenderFns\", function() { return staticRenderFns; });\nvar render = function() {\n var _vm = this\n var _h = _vm.$createElement\n var _c = _vm._self._c || _h\n return _c(\"div\", [\n _c(\"div\", { staticClass: \"form-group\" }, [\n _c(\"label\", [_vm._v(\"Search Users\")]),\n _vm._v(\" \"),\n _c(\"input\", {\n directives: [\n {\n name: \"model\",\n rawName: \"v-model\",\n value: _vm.searchedName,\n expression: \"searchedName\"\n }\n ],\n staticClass: \"form-control\",\n attrs: { type: \"text\", placeholder: \"Search for users\" },\n domProps: { value: _vm.searchedName },\n on: {\n keyup: [\n function($event) {\n if (\n !$event.type.indexOf(\"key\") &&\n _vm._k($event.keyCode, \"down\", 40, $event.key, [\n \"Down\",\n \"ArrowDown\"\n ])\n ) {\n return null\n }\n return _vm.moveCursor(\"down\")\n },\n function($event) {\n if (\n !$event.type.indexOf(\"key\") &&\n _vm._k($event.keyCode, \"up\", 38, $event.key, [\"Up\", \"ArrowUp\"])\n ) {\n return null\n }\n return _vm.moveCursor(\"up\")\n },\n function($event) {\n if (\n !$event.type.indexOf(\"key\") &&\n _vm._k($event.keyCode, \"enter\", 13, $event.key, \"Enter\")\n ) {\n return null\n }\n return _vm.selectUser()\n }\n ],\n input: function($event) {\n if ($event.target.composing) {\n return\n }\n _vm.searchedName = $event.target.value\n }\n }\n })\n ]),\n _vm._v(\" \"),\n _c(\n \"div\",\n { staticClass: \"form-group\" },\n _vm._l(_vm.selectedUsers, function(user) {\n return _c(\n \"span\",\n { key: user.id, staticClass: \"badge badge-primary mr-1\" },\n [\n _vm._v(\"\\n \" + _vm._s(user.name) + \"\\n \"),\n _c(\n \"a\",\n {\n staticClass: \"btn-fa\",\n on: {\n click: function($event) {\n return _vm.removeSelectedUser(user.id)\n }\n }\n },\n [_vm._v(\" ×\")]\n )\n ]\n )\n }),\n 0\n ),\n _vm._v(\" \"),\n _c(\"div\", { staticClass: \"form-group\" }, [\n _vm.userResults.length == 0 &&\n this.searchedName != \"\" &&\n _vm.awaitingSearch == false\n ? _c(\"div\", { staticClass: \"text-center\" }, [\n _c(\"span\", { staticClass: \"text-muted\" }, [\n _vm._v(\"\\n No users found\\n \")\n ])\n ])\n : _vm._e(),\n _vm._v(\" \"),\n _c(\n \"ul\",\n { staticClass: \"list-group\" },\n _vm._l(_vm.userResults, function(user, idx) {\n return _c(\n \"li\",\n {\n key: user.id,\n class: {\n \"list-group-item\": true,\n active: idx === _vm.selectedResultIdx\n },\n on: {\n click: function($event) {\n return _vm.selectUser(idx)\n }\n }\n },\n [\n _vm._v(\"\\n \" + _vm._s(user.name) + \"\\n \"),\n user.team_id\n ? _c(\n \"small\",\n {\n class: {\n \"float-right\": true,\n \"text-white\": idx === _vm.selectedResultIdx,\n \"text-muted\": idx !== _vm.selectedResultIdx\n }\n },\n [_vm._v(\"\\n already in a team\\n \")]\n )\n : _vm._e()\n ]\n )\n }),\n 0\n )\n ]),\n _vm._v(\" \"),\n _c(\"div\", { staticClass: \"form-group\" }, [\n _c(\n \"button\",\n {\n staticClass: \"btn btn-success d-inline-block float-right\",\n on: {\n click: function($event) {\n return _vm.addUsers()\n }\n }\n },\n [_vm._v(\"\\n Add Users\\n \")]\n )\n ])\n ])\n}\nvar staticRenderFns = []\nrender._withStripped = true\n\n\n\n//# sourceURL=webpack:///./CTFd/themes/admin/assets/js/components/teams/UserAddForm.vue?./node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!./node_modules/vue-loader/lib??vue-loader-options"); + +/***/ }), + /***/ "./node_modules/vue-style-loader/index.js!./node_modules/css-loader/dist/cjs.js!./node_modules/vue-loader/lib/loaders/stylePostLoader.js!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/comments/CommentBox.vue?vue&type=style&index=0&id=1fd2c08a&scoped=true&lang=css&": /*!******************************************************************************************************************************************************************************************************************************************************************************************************************!*\ !*** ./node_modules/vue-style-loader!./node_modules/css-loader/dist/cjs.js!./node_modules/vue-loader/lib/loaders/stylePostLoader.js!./node_modules/vue-loader/lib??vue-loader-options!./CTFd/themes/admin/assets/js/components/comments/CommentBox.vue?vue&type=style&index=0&id=1fd2c08a&scoped=true&lang=css& ***! diff --git a/CTFd/themes/admin/static/js/components.min.js b/CTFd/themes/admin/static/js/components.min.js index e4af15ee..a379b0d7 100644 --- a/CTFd/themes/admin/static/js/components.min.js +++ b/CTFd/themes/admin/static/js/components.min.js @@ -1 +1 @@ -(window.webpackJsonp=window.webpackJsonp||[]).push([[0],{"./CTFd/themes/admin/assets/js/components/comments/CommentBox.vue":function(e,t,s){s.r(t);var n,i=s("./CTFd/themes/admin/assets/js/components/comments/CommentBox.vue?vue&type=template&id=1fd2c08a&scoped=true&"),a=s("./CTFd/themes/admin/assets/js/components/comments/CommentBox.vue?vue&type=script&lang=js&");for(n in a)"default"!==n&&function(e){s.d(t,e,function(){return a[e]})}(n);s("./CTFd/themes/admin/assets/js/components/comments/CommentBox.vue?vue&type=style&index=0&id=1fd2c08a&scoped=true&lang=css&");var o=s("./node_modules/vue-loader/lib/runtime/componentNormalizer.js"),l=Object(o.a)(a.default,i.a,i.b,!1,null,"1fd2c08a",null);l.options.__file="CTFd/themes/admin/assets/js/components/comments/CommentBox.vue",t.default=l.exports},"./CTFd/themes/admin/assets/js/components/comments/CommentBox.vue?vue&type=script&lang=js&":function(e,t,s){s.r(t);var n,i=s("./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/comments/CommentBox.vue?vue&type=script&lang=js&"),a=s.n(i);for(n in i)"default"!==n&&function(e){s.d(t,e,function(){return i[e]})}(n);t.default=a.a},"./CTFd/themes/admin/assets/js/components/comments/CommentBox.vue?vue&type=style&index=0&id=1fd2c08a&scoped=true&lang=css&":function(e,t,s){var n=s("./node_modules/vue-style-loader/index.js!./node_modules/css-loader/dist/cjs.js!./node_modules/vue-loader/lib/loaders/stylePostLoader.js!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/comments/CommentBox.vue?vue&type=style&index=0&id=1fd2c08a&scoped=true&lang=css&");s.n(n).a},"./CTFd/themes/admin/assets/js/components/comments/CommentBox.vue?vue&type=template&id=1fd2c08a&scoped=true&":function(e,t,s){function n(){var s=this,e=s.$createElement,n=s._self._c||e;return n("div",[n("div",{staticClass:"row mb-3"},[n("div",{staticClass:"col-md-12"},[n("div",{staticClass:"comment"},[n("textarea",{directives:[{name:"model",rawName:"v-model.lazy",value:s.comment,expression:"comment",modifiers:{lazy:!0}}],staticClass:"form-control mb-2",attrs:{rows:"2",id:"comment-input",placeholder:"Add comment"},domProps:{value:s.comment},on:{change:function(e){s.comment=e.target.value}}}),s._v(" "),n("button",{staticClass:"btn btn-sm btn-success btn-outlined float-right",attrs:{type:"submit"},on:{click:function(e){return s.submitComment()}}},[s._v("\n Comment\n ")])])])]),s._v(" "),1>>\n ")])])]),s._v(" "),n("div",{staticClass:"col-md-12"},[n("div",{staticClass:"text-center"},[n("small",{staticClass:"text-muted"},[s._v("Page "+s._s(s.page)+" of "+s._s(s.total)+" comments")])])])]):s._e(),s._v(" "),n("div",{staticClass:"comments"},[n("transition-group",{attrs:{name:"comment-card"}},s._l(s.comments,function(t){return n("div",{key:t.id,staticClass:"comment-card card mb-2"},[n("div",{staticClass:"card-body pl-0 pb-0 pt-2 pr-2"},[n("button",{staticClass:"close float-right",attrs:{type:"button","aria-label":"Close"},on:{click:function(e){return s.deleteComment(t.id)}}},[n("span",{attrs:{"aria-hidden":"true"}},[s._v("×")])])]),s._v(" "),n("div",{staticClass:"card-body"},[n("div",{staticClass:"card-text",domProps:{innerHTML:s._s(t.html)}}),s._v(" "),n("small",{staticClass:"text-muted float-left"},[n("span",[n("a",{attrs:{href:s.urlRoot+"/admin/users/"+t.author_id}},[s._v(s._s(t.author.name))])])]),s._v(" "),n("small",{staticClass:"text-muted float-right"},[n("span",{staticClass:"float-right"},[s._v(s._s(s.toLocalTime(t.date)))])])])])}),0)],1),s._v(" "),1 >>\n ")])])]),s._v(" "),n("div",{staticClass:"col-md-12"},[n("div",{staticClass:"text-center"},[n("small",{staticClass:"text-muted"},[s._v("Page "+s._s(s.page)+" of "+s._s(s.total)+" comments")])])])]):s._e()])}var i=[];n._withStripped=!0,s.d(t,"a",function(){return n}),s.d(t,"b",function(){return i})},"./CTFd/themes/admin/assets/js/components/configs/fields/Field.vue":function(e,t,s){s.r(t);var n,i=s("./CTFd/themes/admin/assets/js/components/configs/fields/Field.vue?vue&type=template&id=30e0f744&scoped=true&"),a=s("./CTFd/themes/admin/assets/js/components/configs/fields/Field.vue?vue&type=script&lang=js&");for(n in a)"default"!==n&&function(e){s.d(t,e,function(){return a[e]})}(n);var o=s("./node_modules/vue-loader/lib/runtime/componentNormalizer.js"),l=Object(o.a)(a.default,i.a,i.b,!1,null,"30e0f744",null);l.options.__file="CTFd/themes/admin/assets/js/components/configs/fields/Field.vue",t.default=l.exports},"./CTFd/themes/admin/assets/js/components/configs/fields/Field.vue?vue&type=script&lang=js&":function(e,t,s){s.r(t);var n,i=s("./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/configs/fields/Field.vue?vue&type=script&lang=js&"),a=s.n(i);for(n in i)"default"!==n&&function(e){s.d(t,e,function(){return i[e]})}(n);t.default=a.a},"./CTFd/themes/admin/assets/js/components/configs/fields/Field.vue?vue&type=template&id=30e0f744&scoped=true&":function(e,t,s){function n(){var o=this,e=o.$createElement,t=o._self._c||e;return t("div",{staticClass:"border-bottom"},[t("div",[t("button",{staticClass:"close float-right",attrs:{type:"button","aria-label":"Close"},on:{click:function(e){return o.deleteField()}}},[t("span",{attrs:{"aria-hidden":"true"}},[o._v("×")])])]),o._v(" "),t("div",{staticClass:"row"},[t("div",{staticClass:"col-md-3"},[t("div",{staticClass:"form-group"},[t("label",[o._v("Field Type")]),o._v(" "),t("select",{directives:[{name:"model",rawName:"v-model.lazy",value:o.field.field_type,expression:"field.field_type",modifiers:{lazy:!0}}],staticClass:"form-control custom-select",on:{change:function(e){var t=Array.prototype.filter.call(e.target.options,function(e){return e.selected}).map(function(e){return"_value"in e?e._value:e.value});o.$set(o.field,"field_type",e.target.multiple?t:t[0])}}},[t("option",{attrs:{value:"text"}},[o._v("Text Field")]),o._v(" "),t("option",{attrs:{value:"boolean"}},[o._v("Checkbox")])]),o._v(" "),t("small",{staticClass:"form-text text-muted"},[o._v("Type of field shown to the user")])])]),o._v(" "),t("div",{staticClass:"col-md-9"},[t("div",{staticClass:"form-group"},[t("label",[o._v("Field Name")]),o._v(" "),t("input",{directives:[{name:"model",rawName:"v-model.lazy",value:o.field.name,expression:"field.name",modifiers:{lazy:!0}}],staticClass:"form-control",attrs:{type:"text"},domProps:{value:o.field.name},on:{change:function(e){return o.$set(o.field,"name",e.target.value)}}}),o._v(" "),t("small",{staticClass:"form-text text-muted"},[o._v("Field name")])])]),o._v(" "),t("div",{staticClass:"col-md-12"},[t("div",{staticClass:"form-group"},[t("label",[o._v("Field Description")]),o._v(" "),t("input",{directives:[{name:"model",rawName:"v-model.lazy",value:o.field.description,expression:"field.description",modifiers:{lazy:!0}}],staticClass:"form-control",attrs:{type:"text"},domProps:{value:o.field.description},on:{change:function(e){return o.$set(o.field,"description",e.target.value)}}}),o._v(" "),t("small",{staticClass:"form-text text-muted",attrs:{id:"emailHelp"}},[o._v("Field Description")])])]),o._v(" "),t("div",{staticClass:"col-md-12"},[t("div",{staticClass:"form-check"},[t("label",{staticClass:"form-check-label"},[t("input",{directives:[{name:"model",rawName:"v-model.lazy",value:o.field.editable,expression:"field.editable",modifiers:{lazy:!0}}],staticClass:"form-check-input",attrs:{type:"checkbox"},domProps:{checked:Array.isArray(o.field.editable)?-1 "+_this.createForm+"").find("script").each(function(){eval((0,_jquery.default)(this).html())})},100)})},loadTypes:function(){var t=this;_CTFd.default.fetch("/api/v1/flags/types",{method:"GET"}).then(function(e){return e.json()}).then(function(e){t.types=e.data})},submitFlag:function(e){var t=this,s=(0,_jquery.default)(e.target).serializeJSON(!0);s.challenge=this.$props.challenge_id,_CTFd.default.fetch("/api/v1/flags",{method:"POST",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"},body:JSON.stringify(s)}).then(function(e){return e.json()}).then(function(e){t.$emit("refreshFlags",t.$options.name)})}},created:function(){this.loadTypes()}};exports.default=_default},"./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/flags/FlagEditForm.vue?vue&type=script&lang=js&":function(module,exports,__webpack_require__){Object.defineProperty(exports,"__esModule",{value:!0}),exports.default=void 0;var _jquery=_interopRequireDefault(__webpack_require__("./node_modules/jquery/dist/jquery.js")),_CTFd=_interopRequireDefault(__webpack_require__("./CTFd/themes/core/assets/js/CTFd.js")),_nunjucks=_interopRequireDefault(__webpack_require__("./node_modules/nunjucks/browser/nunjucks.js"));function _interopRequireDefault(e){return e&&e.__esModule?e:{default:e}}var _default={name:"FlagEditForm",props:{flag_id:Number},data:function(){return{flag:{},editForm:""}},watch:{flag_id:{immediate:!0,handler:function(e){null!==e&&this.loadFlag()}}},methods:{loadFlag:function loadFlag(){var _this=this;_CTFd.default.fetch("/api/v1/flags/".concat(this.$props.flag_id),{method:"GET"}).then(function(e){return e.json()}).then(function(response){_this.flag=response.data;var editFormURL=_this.flag.templates.update;_jquery.default.get(_CTFd.default.config.urlRoot+editFormURL,function(template_data){var template=_nunjucks.default.compile(template_data);_this.editForm=template.render(_this.flag),_this.editForm.includes("