diff --git a/.gitignore b/.gitignore index 0261230688..5bd5bbee9c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Reports +reports/* + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8691c0e239..298e05a3da 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,6 +1,8 @@ stages: - - linting + - dependencies + - lint - test + - sast - containerize variables: @@ -10,94 +12,217 @@ variables: AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY MYSQL_ROOT_PASSWORD: password + PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" + YARN_CACHE_FOLDER: "$CI_PROJECT_DIR/.cache/yarn" + SAST_EXCLUDED_PATHS: "spec, test, tests, tmp, populate.py" -dockerfile: - stage: linting +include: +- template: Security/SAST.gitlab-ci.yml + +python dependencies: + stage: dependencies + image: nikolaik/python-nodejs:python3.9-nodejs18 + script: + - pip install virtualenv + - virtualenv venv + - source venv/bin/activate + - python -m pip install --upgrade pip + - python -m pip install -r development.txt + artifacts: + name: Python virtual environment + paths: + - venv + expire_in: 24 hours + cache: + - key: pip-cache + paths: + - "$PIP_CACHE_DIR" + - key: yarn-cache + paths: + - "$YARN_CACHE_FOLDER" + +node dependencies: + stage: dependencies + image: nikolaik/python-nodejs:python3.9-nodejs18 + script: + - yarn install --non-interactive + artifacts: + name: Node modules + paths: + - node_modules + expire_in: 24 hours + +lint dockerfile: + stage: lint image: hadolint/hadolint:latest-debian + needs: [] script: - mkdir -p reports - hadolint -f gitlab_codeclimate Dockerfile > reports/hadolint-$(md5sum Dockerfile | cut -d" " -f1).json - artifacts: - name: "$CI_JOB_NAME artifacts from $CI_PROJECT_NAME on $CI_COMMIT_REF_SLUG" - reports: - codequality: - - "reports/*" - paths: - - "reports/*" -docker-compose: - stage: linting +lint docker-compose: + stage: lint image: python:3.9.13-bullseye + needs: [] script: - python -m pip install docker-compose==1.26.0 - docker-compose -f docker-compose.yml config -postgres: +flake8: + stage: lint + image: nikolaik/python-nodejs:python3.9-nodejs18 + dependencies: + - python dependencies + needs: + - python dependencies + script: + - source venv/bin/activate + - flake8 --ignore=E402,E501,E712,W503,E203 --exclude=CTFd/uploads CTFd/ migrations/ tests/ + +black: + stage: lint + image: nikolaik/python-nodejs:python3.9-nodejs18 + dependencies: + - python dependencies + needs: + - python dependencies + script: + - source venv/bin/activate + - black --check --diff --exclude=CTFd/uploads --exclude=node_modules . + +yarn lint: + stage: lint + image: nikolaik/python-nodejs:python3.9-nodejs18 + dependencies: + - node dependencies + needs: + - node dependencies + script: + - yarn lint + +prettier: + stage: lint + image: nikolaik/python-nodejs:python3.9-nodejs18 + dependencies: + - node dependencies + needs: + - node dependencies + script: + - yarn global add prettier@1.17.0 + - prettier --check 'CTFd/themes/**/assets/**/*' + - prettier --check '**/*.md' + +.pytest: stage: test image: nikolaik/python-nodejs:python3.9-nodejs18 + dependencies: + - python dependencies + needs: + - python dependencies + - node dependencies + - flake8 + - black + script: + - source venv/bin/activate + - rm -f /etc/boto.cfg + - | + pytest -rf --cov=CTFd --cov-context=test --cov-report=xml:reports/coverage/${DB_DRIVER}pytest.xml \ + --junitxml=reports/tests/pytest.xml \ + --ignore-glob="**/node_modules/" \ + --ignore=node_modules/ \ + -W ignore::sqlalchemy.exc.SADeprecationWarning \ + -W ignore::sqlalchemy.exc.SAWarning \ + -n auto + artifacts: + reports: + coverage_report: + coverage_format: cobertura + path: "reports/coverage/*.xml" + junit: "reports/tests/*.xml" + +postgres: + extends: .pytest timeout: 24 hours services: - - postgres:latest - redis:latest + - postgres:latest variables: TESTING_DATABASE_URL: postgres://postgres:password@postgres:5432/ctfd - script: - - python -m pip install --upgrade pip - - python -m pip install -r development.txt - - yarn install --non-interactive - - rm -f /etc/boto.cfg - - make test - artifacts: - paths: - - coverage.xml + DB_DRIVER: "postgres" when: manual mysql: - stage: test - image: nikolaik/python-nodejs:python3.9-nodejs18 + extends: .pytest timeout: 24 hours services: - mysql:5.7 - redis:latest variables: TESTING_DATABASE_URL: mysql+pymysql://root:password@mysql:3306/ctfd - script: - - python -m pip install --upgrade pip - - python -m pip install -r development.txt - - yarn install --non-interactive - - rm -f /etc/boto.cfg - - make test - artifacts: - paths: - - coverage.xml + DB_DRIVER: "mysql" when: manual sqlite: - stage: test - image: nikolaik/python-nodejs:python3.9-nodejs18 + extends: .pytest + timeout: 15 minutes + services: + - mysql:5.7 + - redis:latest variables: TESTING_DATABASE_URL: 'sqlite://' + DB_DRIVER: "sqlite" + +bandit: + image: nikolaik/python-nodejs:python3.9-nodejs18 + dependencies: + - python dependencies + needs: + - python dependencies + - flake8 + - black script: - - python -m pip install --upgrade pip - - python -m pip install -r development.txt - - yarn install --non-interactive - - yarn global add prettier@1.17.0 - - rm -f /etc/boto.cfg - - make test - artifacts: - paths: - - coverage.xml + - source venv/bin/activate + - bandit -r CTFd -x CTFd/uploads --skip B105,B322 + +yarn verify: + image: nikolaik/python-nodejs:python3.9-nodejs18 + dependencies: + - node dependencies + needs: + - node dependencies + - yarn lint + - prettier + script: + - yarn verify + +sast: + dependencies: + - python dependencies + - node dependencies + needs: + - python dependencies + - node dependencies containerize: stage: containerize image: name: gcr.io/kaniko-project/executor:debug entrypoint: [""] + needs: + - sqlite + - lint dockerfile + - bandit + - yarn verify script: + - | + if [[ "${CI_COMMIT_BRANCH}" != "${CI_DEFAULT_BRANCH}" ]]; + then + SUFFIX="/${CI_COMMIT_REF_SLUG}" + fi - mkdir -p /kaniko/.docker - echo "{\"auths\":{\"${CI_REGISTRY}\":{\"auth\":\"$(printf "%s:%s" "${CI_REGISTRY_USER}" "${CI_REGISTRY_PASSWORD}" | base64 | tr -d '\n')\"}}}" > /kaniko/.docker/config.json - >- /kaniko/executor --context "${CI_PROJECT_DIR}" --dockerfile "${CI_PROJECT_DIR}/Dockerfile" - --destination "${CI_REGISTRY_IMAGE}:${CI_COMMIT_TAG}" \ No newline at end of file + --destination "${CI_REGISTRY_IMAGE}${SUFFIX}:${CI_COMMIT_TAG}" \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c1c40362cf..f0a7b75825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,79 @@ +# 3.5.1 / 2023-01-23 + +**General** + +- The public scoreboard page is no longer shown to users if account visibility is disabled +- Teams created by admins using the normal team creation flow are now hidden by default +- Redirect users to the team creation page if they access a certain pages before the CTF starts +- Added a notice on the Challenges page to remind Admins if they are in Admins Only mode +- Fixed an issue where users couldn't login to their team even though they were already on the team +- Fixed an issue with scoreboard tie breaking when an award results in a tie +- Fixed the order of solves, fails, and awards to always be in chronological ordering (latest first). +- Fixed an issue where certain custom fields could not be submitted + +**Admin Panel** + +- Improved the rendering of Admin Panel tables on mobile devices +- Clarified the behavior of Score Visibility with respect to Account Visibility in the Admin Panel help text +- Added user id and user email fields to the user mode scoreboard CSV export +- Add CSV export for `teams+members+fields` which is teams with Custom Field entries and their team members with Custom Field entries +- The import process will now catch all exceptions in the import process to report them in the Admin Panel +- Fixed issue where `field_entries` could not be imported under MariaDB +- Fixed issue where `config` entries sometimes would be recreated for some reason causing an import to fail +- Fixed issue with Firefox caching checkboxes by adding `autocomplete='off'` to Admin Panel pages +- Fixed issue where Next selection for a challenge wouldn't always load in Admin Panel + +**API** + +- Improve response time of `/api/v1/challenges` and `/api/v1/challenges/[challenge_id]/solves` by caching the solve count data for users and challenges +- Add `HEAD /api/v1/notifications` to get a count of notifications that have happened. + - This also includes a `since_id` parameter to allow for a notification cursor. + - Unread notification count can now be tracked by themes that track which notifications a user has read +- Add `since_id` to `GET /api/v1/notifications` to get Notifications that have happened since a specific ID + +**Deployment** + +- Imports have been disabled when running with a SQLite database backend + - See https://github.com/CTFd/CTFd/issues/2131 +- Added `/healthcheck` endpoint to check if CTFd is ready +- There are now ARM Docker images for OSS CTFd +- Bump dependencies for passlib, bcrypt, requests, gunicorn, gevent, python-geoacumen-city, cmarkgfm +- Properly load `SAFE_MODE` config from environment variable +- The `AWS_S3_REGION` config has been added to allow specifying an S3 region. The default is `us-east-1` +- Add individual DATABASE config keys as an alternative to `DATABASE_URL` + - `DATABASE_PROTOCOL`: SQLAlchemy DB protocol (+ driver, optionally) + - `DATABASE_USER`: Username to access DB server with + - `DATABASE_PASSWORD`: Password to access DB server with + - `DATABASE_HOST`: Hostname of the DB server to access + - `DATABASE_PORT`: Port of the DB server to access + - `DATABASE_NAME`: Name of the database to use +- Add individual REDIS config keys as an alternative to `REDIS_URL` + - `REDIS_PROTOCOL`: Protocol to access Redis server with (either redis or rediss) + - `REDIS_USER`: Username to access Redis server with + - `REDIS_PASSWORD`: Password to access Redis server with + - `REDIS_HOST`: Hostname of the Redis server to access + - `REDIS_PORT`: Port of the Redis server to access + - `REDIS_DB`: Numeric ID of the database to access + +**Plugins** + +- Adds support for `config.json` to have multiple paths to add to the Plugins dropdown in the Admin Panel +- Plugins and their migrations now have access to the `get_all_tables` and `get_columns_for_table` functions +- Email sending functions have now been seperated into classes that can be customized via plugins. + - Add `CTFd.utils.email.providers.EmailProvider` + - Add `CTFd.utils.email.providers.mailgun.MailgunEmailProvider` + - Add `CTFd.utils.email.providers.smtp.SMTPEmailProvider` + - Deprecate `CTFd.utils.email.mailgun.sendmail` + - Deprecate `CTFd.utils.email.smtp.sendmail` + +**Themes** + +- The beta interface `Assets.manifest_css` has been removed +- `event-source-polyfill` is now pinned to 1.0.19. + - See https://github.com/CTFd/CTFd/issues/2159 + - Note that we will not be using this polyfill starting with the `core-beta` theme. +- Add autofocus to text fields on authentication pages + # 3.5.0 / 2022-05-09 **General** diff --git a/CTFd/__init__.py b/CTFd/__init__.py index 909c64ef08..144126f281 100644 --- a/CTFd/__init__.py +++ b/CTFd/__init__.py @@ -29,7 +29,7 @@ from CTFd.utils.sessions import CachingSessionInterface from CTFd.utils.updates import update_check -__version__ = "3.5.0" +__version__ = "3.5.1" __channel__ = "oss" diff --git a/CTFd/admin/__init__.py b/CTFd/admin/__init__.py index 20f96ef650..dedb2ea1ae 100644 --- a/CTFd/admin/__init__.py +++ b/CTFd/admin/__init__.py @@ -26,7 +26,13 @@ from CTFd.admin import submissions # noqa: F401 from CTFd.admin import teams # noqa: F401 from CTFd.admin import users # noqa: F401 -from CTFd.cache import cache, clear_config, clear_pages, clear_standings +from CTFd.cache import ( + cache, + clear_challenges, + clear_config, + clear_pages, + clear_standings, +) from CTFd.models import ( Awards, Challenges, @@ -238,6 +244,7 @@ def reset(): clear_pages() clear_standings() + clear_challenges() clear_config() if logout is True: diff --git a/CTFd/api/v1/challenges.py b/CTFd/api/v1/challenges.py index 8d141a1cf7..7cf734c88f 100644 --- a/CTFd/api/v1/challenges.py +++ b/CTFd/api/v1/challenges.py @@ -1,15 +1,13 @@ -import datetime from typing import List from flask import abort, render_template, request, url_for from flask_restx import Namespace, Resource -from sqlalchemy import func as sa_func -from sqlalchemy.sql import and_, false, true +from sqlalchemy.sql import and_ from CTFd.api.v1.helpers.request import validate_args from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse -from CTFd.cache import clear_standings +from CTFd.cache import clear_challenges, clear_standings from CTFd.constants import RawEnum from CTFd.models import ChallengeFiles as ChallengeFilesModel from CTFd.models import Challenges @@ -22,12 +20,18 @@ from CTFd.schemas.tags import TagSchema from CTFd.utils import config, get_config from CTFd.utils import user as current_user +from CTFd.utils.challenges import ( + get_all_challenges, + get_solve_counts_for_challenges, + get_solve_ids_for_user_id, + get_solves_for_challenge_id, +) from CTFd.utils.config.visibility import ( accounts_visible, challenges_visible, scores_visible, ) -from CTFd.utils.dates import ctf_ended, ctf_paused, ctftime, isoformat, unix_time_to_utc +from CTFd.utils.dates import ctf_ended, ctf_paused, ctftime from CTFd.utils.decorators import ( admins_only, during_ctf_time_only, @@ -37,9 +41,7 @@ check_challenge_visibility, check_score_visibility, ) -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, @@ -77,60 +79,6 @@ class ChallengeListSuccessResponse(APIListSuccessResponse): ) -def _build_solves_query(extra_filters=(), admin_view=False): - """Returns queries and data that that are used for showing an account's solves. - It returns a tuple of - - SQLAlchemy query with (challenge_id, solve_count_for_challenge_id) - - Current user's solved challenge IDs - """ - # This can return None (unauth) if visibility is set to public - user = get_current_user() - # We only set a condition for matching user solves if there is a user and - # they have an account ID (user mode or in a team in teams mode) - AccountModel = get_model() - if user is not None and user.account_id is not None: - user_solved_cond = Solves.account_id == user.account_id - else: - user_solved_cond = false() - # We have to filter solves to exclude any made after the current freeze - # time unless we're in an admin view as determined by the caller. - freeze = get_config("freeze") - if freeze and not admin_view: - freeze_cond = Solves.date < unix_time_to_utc(freeze) - else: - freeze_cond = true() - # Finally, we never count solves made by hidden or banned users/teams, even - # if we are an admin. This is to match the challenge detail API. - exclude_solves_cond = and_( - AccountModel.banned == false(), AccountModel.hidden == false(), - ) - # This query counts the number of solves per challenge, as well as the sum - # of correct solves made by the current user per the condition above (which - # should probably only be 0 or 1!) - solves_q = ( - db.session.query(Solves.challenge_id, sa_func.count(Solves.challenge_id),) - .join(AccountModel) - .filter(*extra_filters, freeze_cond, exclude_solves_cond) - .group_by(Solves.challenge_id) - ) - # Also gather the user's solve items which can be different from above query - # For example, even if we are a hidden user, we should see that we have solved a challenge - # however as a hidden user we are not included in the count of the above query - if admin_view: - # If we're an admin we should show all challenges as solved to break through any requirements - challenges = Challenges.query.all() - solve_ids = {challenge.id for challenge in challenges} - else: - # If not an admin we calculate solves as normal - solve_ids = ( - Solves.query.with_entities(Solves.challenge_id) - .filter(user_solved_cond) - .all() - ) - solve_ids = {value for value, in solve_ids} - return solves_q, solve_ids - - @challenges_namespace.route("") class ChallengeList(Resource): @check_challenge_visibility @@ -185,23 +133,22 @@ def get(self, query_args): # 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) # Admins get a shortcut to see all challenges despite pre-requisites admin_view = is_admin() and request.args.get("view") == "admin" - solve_counts = {} - # 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, user_solves = _build_solves_query(admin_view=admin_view) + # Get a cached mapping of challenge_id to solve_count + solve_counts = get_solve_counts_for_challenges(admin=admin_view) + + # Get list of solve_ids for current user + if authed(): + user = get_current_user() + user_solves = get_solve_ids_for_user_id(user_id=user.id) + else: + user_solves = set() + # Aggregate the query results into the hashes defined at the top of # this block for later use - for chal_id, solve_count in solves_q: - solve_counts[chal_id] = solve_count if scores_visible() and accounts_visible(): solve_count_dfl = 0 else: @@ -211,18 +158,7 @@ def get(self, query_args): # `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, Challenges.id) - ) + chal_q = get_all_challenges(admin=admin_view, field=field, q=q, **query_args) # Iterate through the list of challenges, adding to the object which # will be JSONified back to the client @@ -308,6 +244,9 @@ def post(self): challenge_class = get_chal_class(challenge_type) challenge = challenge_class.create(request) response = challenge_class.read(challenge) + + clear_challenges() + return {"success": True, "data": response} @@ -453,13 +392,17 @@ def get(self, challenge_id): response = chal_class.read(challenge=chal) - solves_q, user_solves = _build_solves_query( - 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: - challenge_id, solve_count = maybe_row + # Get list of solve_ids for current user + if authed(): + user = get_current_user() + user_solves = get_solve_ids_for_user_id(user_id=user.id) + else: + user_solves = [] + + solves_count = get_solve_counts_for_challenges(challenge_id=chal.id) + if solves_count: + challenge_id = chal.id + solve_count = solves_count.get(chal.id) solved_by_user = challenge_id in user_solves else: solve_count, solved_by_user = 0, False @@ -522,6 +465,10 @@ def patch(self, challenge_id): challenge_class = get_chal_class(challenge.type) challenge = challenge_class.update(challenge, request) response = challenge_class.read(challenge) + + clear_standings() + clear_challenges() + return {"success": True, "data": response} @admins_only @@ -534,6 +481,9 @@ def delete(self, challenge_id): chal_class = get_chal_class(challenge.type) chal_class.delete(challenge) + clear_standings() + clear_challenges() + return {"success": True} @@ -675,6 +625,7 @@ def post(self): user=user, team=team, challenge=challenge, request=request ) clear_standings() + clear_challenges() log( "submissions", @@ -694,6 +645,7 @@ def post(self): user=user, team=team, challenge=challenge, request=request ) clear_standings() + clear_challenges() log( "submissions", @@ -762,41 +714,15 @@ def get(self, challenge_id): if challenge.state == "hidden" and is_admin() is False: abort(404) - Model = get_model() - - # Note that we specifically query for the Solves.account.name - # attribute here because it is faster than having SQLAlchemy - # query for the attribute directly and it's unknown what the - # affects of changing the relationship lazy attribute would be - solves = ( - Solves.query.add_columns(Model.name.label("account_name")) - .join(Model, Solves.account_id == Model.id) - .filter( - Solves.challenge_id == challenge_id, - Model.banned == False, - Model.hidden == False, - ) - .order_by(Solves.date.asc()) - ) - freeze = get_config("freeze") if freeze: preview = request.args.get("preview") if (is_admin() is False) or (is_admin() is True and preview): - dt = datetime.datetime.utcfromtimestamp(freeze) - solves = solves.filter(Solves.date < dt) + freeze = True + elif is_admin() is True: + freeze = False - for solve in solves: - # Seperate out the account name and the Solve object from the SQLAlchemy tuple - solve, account_name = solve - response.append( - { - "account_id": solve.account_id, - "name": account_name, - "date": isoformat(solve.date), - "account_url": generate_account_url(account_id=solve.account_id), - } - ) + response = get_solves_for_challenge_id(challenge_id=challenge_id, freeze=freeze) return {"success": True, "data": response} diff --git a/CTFd/api/v1/config.py b/CTFd/api/v1/config.py index 9a734717dd..6401508a65 100644 --- a/CTFd/api/v1/config.py +++ b/CTFd/api/v1/config.py @@ -6,7 +6,7 @@ from CTFd.api.v1.helpers.request import validate_args from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse -from CTFd.cache import clear_config, clear_standings +from CTFd.cache import clear_challenges, clear_config, clear_standings from CTFd.constants import RawEnum from CTFd.models import Configs, Fields, db from CTFd.schemas.config import ConfigSchema @@ -99,6 +99,7 @@ def post(self): clear_config() clear_standings() + clear_challenges() return {"success": True, "data": response.data} @@ -119,6 +120,7 @@ def patch(self): clear_config() clear_standings() + clear_challenges() return {"success": True} @@ -175,6 +177,7 @@ def patch(self, config_key): clear_config() clear_standings() + clear_challenges() return {"success": True, "data": response.data} @@ -192,6 +195,7 @@ def delete(self, config_key): clear_config() clear_standings() + clear_challenges() return {"success": True} diff --git a/CTFd/api/v1/submissions.py b/CTFd/api/v1/submissions.py index d2b9b0513d..a5494d3000 100644 --- a/CTFd/api/v1/submissions.py +++ b/CTFd/api/v1/submissions.py @@ -8,7 +8,7 @@ APIDetailedSuccessResponse, PaginatedAPIListSuccessResponse, ) -from CTFd.cache import clear_standings +from CTFd.cache import clear_challenges, clear_standings from CTFd.constants import RawEnum from CTFd.models import Submissions, db from CTFd.schemas.submissions import SubmissionSchema @@ -141,6 +141,8 @@ def post(self, json_args): # Delete standings cache clear_standings() + # Delete challenges cache + clear_challenges() return {"success": True, "data": response.data} @@ -188,5 +190,6 @@ def delete(self, submission_id): # Delete standings cache clear_standings() + clear_challenges() return {"success": True} diff --git a/CTFd/api/v1/teams.py b/CTFd/api/v1/teams.py index 487220adf5..a3a2ea37e1 100644 --- a/CTFd/api/v1/teams.py +++ b/CTFd/api/v1/teams.py @@ -10,7 +10,12 @@ APIDetailedSuccessResponse, PaginatedAPIListSuccessResponse, ) -from CTFd.cache import clear_standings, clear_team_session, clear_user_session +from CTFd.cache import ( + clear_challenges, + clear_standings, + clear_team_session, + clear_user_session, +) from CTFd.constants import RawEnum from CTFd.models import Awards, Submissions, Teams, Unlocks, Users, db from CTFd.schemas.awards import AwardSchema @@ -155,6 +160,7 @@ def post(self): db.session.close() clear_standings() + clear_challenges() return {"success": True, "data": response.data} @@ -220,6 +226,7 @@ def patch(self, team_id): clear_team_session(team_id=team.id) clear_standings() + clear_challenges() db.session.close() @@ -243,6 +250,7 @@ def delete(self, team_id): clear_team_session(team_id=team_id) clear_standings() + clear_challenges() db.session.close() @@ -375,6 +383,7 @@ def delete(self): clear_team_session(team_id=team.id) clear_standings() + clear_challenges() db.session.close() diff --git a/CTFd/api/v1/users.py b/CTFd/api/v1/users.py index 85065d560f..954c8ee9d3 100644 --- a/CTFd/api/v1/users.py +++ b/CTFd/api/v1/users.py @@ -9,7 +9,7 @@ APIDetailedSuccessResponse, PaginatedAPIListSuccessResponse, ) -from CTFd.cache import clear_standings, clear_user_session +from CTFd.cache import clear_challenges, clear_standings, clear_user_session from CTFd.constants import RawEnum from CTFd.models import ( Awards, @@ -165,6 +165,7 @@ def post(self): user_created_notification(addr=email, name=name, password=password) clear_standings() + clear_challenges() response = schema.dump(response.data) @@ -242,6 +243,7 @@ def patch(self, user_id): clear_user_session(user_id=user_id) clear_standings() + clear_challenges() return {"success": True, "data": response.data} @@ -270,6 +272,7 @@ def delete(self, user_id): clear_user_session(user_id=user_id) clear_standings() + clear_challenges() return {"success": True} @@ -322,6 +325,7 @@ def patch(self): db.session.close() clear_standings() + clear_challenges() return {"success": True, "data": response.data} diff --git a/CTFd/auth.py b/CTFd/auth.py index ce01324f73..3bbb5fa970 100644 --- a/CTFd/auth.py +++ b/CTFd/auth.py @@ -512,7 +512,7 @@ def oauth_redirect(): ) return redirect(url_for("auth.login")) - if get_config("user_mode") == TEAMS_MODE: + if get_config("user_mode") == TEAMS_MODE and user.team_id is None: team_id = api_data["team"]["id"] team_name = api_data["team"]["name"] diff --git a/CTFd/cache/__init__.py b/CTFd/cache/__init__.py index 366ad764ab..94d7bcf0f0 100644 --- a/CTFd/cache/__init__.py +++ b/CTFd/cache/__init__.py @@ -98,6 +98,18 @@ def clear_standings(): cache.delete(make_template_fragment_key(CacheKeys.PUBLIC_SCOREBOARD_TABLE)) +def clear_challenges(): + from CTFd.utils.challenges import get_all_challenges + from CTFd.utils.challenges import get_solves_for_challenge_id + from CTFd.utils.challenges import get_solve_ids_for_user_id + from CTFd.utils.challenges import get_solve_counts_for_challenges + + cache.delete_memoized(get_all_challenges) + cache.delete_memoized(get_solves_for_challenge_id) + cache.delete_memoized(get_solve_ids_for_user_id) + cache.delete_memoized(get_solve_counts_for_challenges) + + def clear_pages(): from CTFd.utils.config.pages import get_page, get_pages diff --git a/CTFd/config.ini b/CTFd/config.ini index 154aa1c0b1..6db6db99ea 100644 --- a/CTFd/config.ini +++ b/CTFd/config.ini @@ -26,17 +26,76 @@ SECRET_KEY = # The URI that specifies the username, password, hostname, port, and database of the server # used to hold the CTFd database. # -# If a database URL is not specified, CTFd will automatically create a SQLite database for you to use +# If neither this setting nor `DATABASE_HOST` is specified, CTFd will automatically create a SQLite database for you to use # e.g. mysql+pymysql://root:@localhost/ctfd DATABASE_URL = +# DATABASE_HOST +# The hostname of the database server used to hold the CTFd database. +# If `DATABASE_URL` is set, this setting will have no effect. +# +# This option, along with the other `DATABASE_*` options, are an alternative to specifying all connection details in the single `DATABASE_URL`. +# If neither this setting nor `DATABASE_URL` is specified, CTFd will automatically create a SQLite database for you to use. +DATABASE_HOST = + +# DATABASE_PROTOCOL +# The protocol used to access the database server, if `DATABASE_HOST` is set. Defaults to `mysql+pymysql`. +DATABASE_PROTOCOL = + +# DATABASE_USER +# The username used to access the database server, if `DATABASE_HOST` is set. Defaults to `ctfd`. +DATABASE_USER = + +# DATABASE_PASSWORD +# The password used to access the database server, if `DATABASE_HOST` is set. +DATABASE_PASSWORD = + +# DATABASE_PORT +# The port used to access the database server, if `DATABASE_HOST` is set. +DATABASE_PORT = + +# DATABASE_NAME +# The name of the database to access on the database server, if `DATABASE_HOST` is set. Defaults to `ctfd`. +DATABASE_NAME = + # REDIS_URL -# The URL to connect to a Redis server. If not specified, CTFd will use the .data folder as a filesystem cache +# The URL to connect to a Redis server. If neither this setting nor `REDIS_HOST` is specified, +# CTFd will use the .data folder as a filesystem cache. # # e.g. redis://user:password@localhost:6379 # http://pythonhosted.org/Flask-Caching/#configuring-flask-caching REDIS_URL = +# REDIS_HOST +# The hostname of the Redis server to connect to. +# If `REDIS_URL` is set, this setting will have no effect. +# +# This option, along with the other `REDIS_*` options, are an alternative to specifying all connection details in the single `REDIS_URL`. +# If neither this setting nor `REDIS_URL` is specified, CTFd will use the .data folder as a filesystem cache. +REDIS_HOST = + +# REDIS_PROTOCOL +# The protocol used to access the Redis server, if `REDIS_HOST` is set. Defaults to `redis`. +# +# Note that the `unix` protocol is not supported here; use `REDIS_URL` instead. +REDIS_PROTOCOL = + +# REDIS_USER +# The username used to access the Redis server, if `REDIS_HOST` is set. +REDIS_USER = + +# REDIS_PASSWORD +# The password used to access the Redis server, if `REDIS_HOST` is set. +REDIS_PASSWORD = + +# REDIS_PORT +# The port used to access the Redis server, if `REDIS_HOST` is set. +REDIS_PORT = + +# REDIS_DB +# The index of the Redis database to access, if `REDIS_HOST` is set. +REDIS_DB = + [security] # SESSION_COOKIE_HTTPONLY # Controls if cookies should be set with the HttpOnly flag. Defaults to True. @@ -102,6 +161,12 @@ MAILGUN_API_KEY = # Installations using the Mailgun API should migrate over to SMTP settings. MAILGUN_BASE_URL = +# MAIL_PROVIDER +# Specifies the email provider that CTFd will use to send email. +# By default CTFd will automatically detect the correct email provider based on the other settings +# specified here or in the configuration panel. This setting can be used to force a specific provider. +MAIL_PROVIDER = + [uploads] # UPLOAD_PROVIDER # Specifies the service that CTFd should use to store files. diff --git a/CTFd/config.py b/CTFd/config.py index c01575f276..52d3725185 100644 --- a/CTFd/config.py +++ b/CTFd/config.py @@ -3,6 +3,8 @@ from distutils.util import strtobool from typing import Union +from sqlalchemy.engine.url import URL + class EnvInterpolation(configparser.BasicInterpolation): """Interpolation which expands environment variables in values.""" @@ -83,13 +85,43 @@ class ServerConfig(object): SECRET_KEY: str = empty_str_cast(config_ini["server"]["SECRET_KEY"]) \ or gen_secret_key() - DATABASE_URL: str = empty_str_cast(config_ini["server"]["DATABASE_URL"]) \ - or f"sqlite:///{os.path.dirname(os.path.abspath(__file__))}/ctfd.db" + DATABASE_URL: str = empty_str_cast(config_ini["server"]["DATABASE_URL"]) + if not DATABASE_URL: + if empty_str_cast(config_ini["server"]["DATABASE_HOST"]) is not None: + # construct URL from individual variables + DATABASE_URL = str(URL( + drivername=empty_str_cast(config_ini["server"]["DATABASE_PROTOCOL"]) or "mysql+pymysql", + username=empty_str_cast(config_ini["server"]["DATABASE_USER"]) or "ctfd", + password=empty_str_cast(config_ini["server"]["DATABASE_PASSWORD"]), + host=empty_str_cast(config_ini["server"]["DATABASE_HOST"]), + port=empty_str_cast(config_ini["server"]["DATABASE_PORT"]), + database=empty_str_cast(config_ini["server"]["DATABASE_NAME"]) or "ctfd", + )) + else: + # default to local SQLite DB + DATABASE_URL = f"sqlite:///{os.path.dirname(os.path.abspath(__file__))}/ctfd.db" REDIS_URL: str = empty_str_cast(config_ini["server"]["REDIS_URL"]) + REDIS_HOST: str = empty_str_cast(config_ini["server"]["REDIS_HOST"]) + REDIS_PROTOCOL: str = empty_str_cast(config_ini["server"]["REDIS_PROTOCOL"]) or "redis" + REDIS_USER: str = empty_str_cast(config_ini["server"]["REDIS_USER"]) + REDIS_PASSWORD: str = empty_str_cast(config_ini["server"]["REDIS_PASSWORD"]) + REDIS_PORT: int = empty_str_cast(config_ini["server"]["REDIS_PORT"]) or 6379 + REDIS_DB: int = empty_str_cast(config_ini["server"]["REDIS_DB"]) or 0 + + if REDIS_URL or REDIS_HOST is None: + CACHE_REDIS_URL = REDIS_URL + else: + # construct URL from individual variables + CACHE_REDIS_URL = f"{REDIS_PROTOCOL}://" + if REDIS_USER: + CACHE_REDIS_URL += REDIS_USER + if REDIS_PASSWORD: + CACHE_REDIS_URL += f":{REDIS_PASSWORD}" + CACHE_REDIS_URL += f"@{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}" + SQLALCHEMY_DATABASE_URI = DATABASE_URL - CACHE_REDIS_URL = REDIS_URL if CACHE_REDIS_URL: CACHE_TYPE: str = "redis" else: @@ -154,6 +186,8 @@ class ServerConfig(object): MAILGUN_BASE_URL: str = empty_str_cast(config_ini["email"]["MAILGUN_API_KEY"]) + MAIL_PROVIDER: str = empty_str_cast(config_ini["email"].get("MAIL_PROVIDER")) + # === LOGS === LOG_FOLDER: str = empty_str_cast(config_ini["logs"]["LOG_FOLDER"]) \ or os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs") diff --git a/CTFd/forms/auth.py b/CTFd/forms/auth.py index a8c73c4fa0..eaf2eee193 100644 --- a/CTFd/forms/auth.py +++ b/CTFd/forms/auth.py @@ -14,7 +14,9 @@ def RegistrationForm(*args, **kwargs): class _RegistrationForm(BaseForm): - name = StringField("User Name", validators=[InputRequired()]) + name = StringField( + "User Name", validators=[InputRequired()], render_kw={"autofocus": True} + ) email = EmailField("Email", validators=[InputRequired()]) password = PasswordField("Password", validators=[InputRequired()]) submit = SubmitField("Submit") @@ -32,7 +34,11 @@ def extra(self): class LoginForm(BaseForm): - name = StringField("User Name or Email", validators=[InputRequired()]) + name = StringField( + "User Name or Email", + validators=[InputRequired()], + render_kw={"autofocus": True}, + ) password = PasswordField("Password", validators=[InputRequired()]) submit = SubmitField("Submit") @@ -42,10 +48,14 @@ class ConfirmForm(BaseForm): class ResetPasswordRequestForm(BaseForm): - email = EmailField("Email", validators=[InputRequired()]) + email = EmailField( + "Email", validators=[InputRequired()], render_kw={"autofocus": True} + ) submit = SubmitField("Submit") class ResetPasswordForm(BaseForm): - password = PasswordField("Password", validators=[InputRequired()]) + password = PasswordField( + "Password", validators=[InputRequired()], render_kw={"autofocus": True} + ) submit = SubmitField("Submit") diff --git a/CTFd/models/__init__.py b/CTFd/models/__init__.py index fc39119371..387b873b9e 100644 --- a/CTFd/models/__init__.py +++ b/CTFd/models/__init__.py @@ -3,6 +3,7 @@ from flask_marshmallow import Marshmallow from flask_sqlalchemy import SQLAlchemy +from sqlalchemy.ext.compiler import compiles from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import column_property, validates @@ -25,6 +26,16 @@ def get_class_by_tablename(tablename): return None +@compiles(db.DateTime, "mysql") +def compile_datetime_mysql(_type, _compiler, **kw): + """ + This decorator makes the default db.DateTime class always enable fsp to enable millisecond precision + https://dev.mysql.com/doc/refman/5.7/en/fractional-seconds.html + https://docs.sqlalchemy.org/en/14/core/custom_types.html#overriding-type-compilation + """ + return "DATETIME(6)" + + class Notifications(db.Model): __tablename__ = "notifications" id = db.Column(db.Integer, primary_key=True) @@ -422,7 +433,7 @@ def get_fields(self, admin=False): def get_solves(self, admin=False): from CTFd.utils import get_config - solves = Solves.query.filter_by(user_id=self.id) + solves = Solves.query.filter_by(user_id=self.id).order_by(Solves.date.desc()) freeze = get_config("freeze") if freeze and admin is False: dt = datetime.datetime.utcfromtimestamp(freeze) @@ -432,7 +443,7 @@ def get_solves(self, admin=False): def get_fails(self, admin=False): from CTFd.utils import get_config - fails = Fails.query.filter_by(user_id=self.id) + fails = Fails.query.filter_by(user_id=self.id).order_by(Fails.date.desc()) freeze = get_config("freeze") if freeze and admin is False: dt = datetime.datetime.utcfromtimestamp(freeze) @@ -442,7 +453,7 @@ def get_fails(self, admin=False): def get_awards(self, admin=False): from CTFd.utils import get_config - awards = Awards.query.filter_by(user_id=self.id) + awards = Awards.query.filter_by(user_id=self.id).order_by(Awards.date.desc()) freeze = get_config("freeze") if freeze and admin is False: dt = datetime.datetime.utcfromtimestamp(freeze) @@ -668,7 +679,7 @@ def get_solves(self, admin=False): member_ids = [member.id for member in self.members] solves = Solves.query.filter(Solves.user_id.in_(member_ids)).order_by( - Solves.date.asc() + Solves.date.desc() ) freeze = get_config("freeze") @@ -684,7 +695,7 @@ def get_fails(self, admin=False): member_ids = [member.id for member in self.members] fails = Fails.query.filter(Fails.user_id.in_(member_ids)).order_by( - Fails.date.asc() + Fails.date.desc() ) freeze = get_config("freeze") @@ -700,7 +711,7 @@ def get_awards(self, admin=False): member_ids = [member.id for member in self.members] awards = Awards.query.filter(Awards.user_id.in_(member_ids)).order_by( - Awards.date.asc() + Awards.date.desc() ) freeze = get_config("freeze") diff --git a/CTFd/plugins/dynamic_challenges/__init__.py b/CTFd/plugins/dynamic_challenges/__init__.py index c9db566172..2459142876 100644 --- a/CTFd/plugins/dynamic_challenges/__init__.py +++ b/CTFd/plugins/dynamic_challenges/__init__.py @@ -144,7 +144,7 @@ def solve(cls, user, team, challenge, request): def load(app): - upgrade() + upgrade(plugin_name="dynamic_challenges") CHALLENGE_CLASSES["dynamic"] = DynamicValueChallenge register_plugin_assets_directory( app, base_path="/plugins/dynamic_challenges/assets/" diff --git a/CTFd/plugins/migrations.py b/CTFd/plugins/migrations.py index 71b3995a23..cf4ddea7f3 100644 --- a/CTFd/plugins/migrations.py +++ b/CTFd/plugins/migrations.py @@ -22,12 +22,14 @@ def get_all_tables(op): return tables -def get_columns_for_table(op, table_name): +def get_columns_for_table(op, table_name, names_only=False): """ Function to list the columns in a table from a migration """ inspector = SQLAInspect(op.get_bind()) columns = inspector.get_columns(table_name) + if names_only is True: + columns = [c["name"] for c in columns] return columns diff --git a/CTFd/scoreboard.py b/CTFd/scoreboard.py index 7e9b3409fd..261813b590 100644 --- a/CTFd/scoreboard.py +++ b/CTFd/scoreboard.py @@ -2,7 +2,10 @@ from CTFd.utils import config from CTFd.utils.config.visibility import scores_visible -from CTFd.utils.decorators.visibility import check_score_visibility +from CTFd.utils.decorators.visibility import ( + check_account_visibility, + check_score_visibility, +) from CTFd.utils.helpers import get_infos from CTFd.utils.scores import get_standings from CTFd.utils.user import is_admin @@ -11,6 +14,7 @@ @scoreboard.route("/scoreboard") +@check_account_visibility @check_score_visibility def listing(): infos = get_infos() diff --git a/CTFd/themes/admin/assets/js/components/next/NextChallenge.vue b/CTFd/themes/admin/assets/js/components/next/NextChallenge.vue index 3a75fcc235..71ba128d04 100644 --- a/CTFd/themes/admin/assets/js/components/next/NextChallenge.vue +++ b/CTFd/themes/admin/assets/js/components/next/NextChallenge.vue @@ -47,7 +47,11 @@ export default { }, computed: { updateAvailable: function() { - return this.selected_id != this.challenge.next_id; + if (this.challenge) { + return this.selected_id != this.challenge.next_id; + } else { + return false; + } }, // Get all challenges besides the current one and current next otherChallenges: function() { diff --git a/CTFd/themes/admin/static/js/components.dev.js b/CTFd/themes/admin/static/js/components.dev.js index 92f2c8464b..4cb158f459 100644 --- a/CTFd/themes/admin/static/js/components.dev.js +++ b/CTFd/themes/admin/static/js/components.dev.js @@ -776,7 +776,7 @@ eval("\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\n /***/ (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\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//\nvar _default = {\n props: {\n challenge_id: Number\n },\n data: function data() {\n return {\n challenge: null,\n challenges: [],\n selected_id: null\n };\n },\n computed: {\n updateAvailable: function updateAvailable() {\n return this.selected_id != this.challenge.next_id;\n },\n // Get all challenges besides the current one and current next\n otherChallenges: function otherChallenges() {\n var _this = this;\n\n return this.challenges.filter(function (challenge) {\n return challenge.id !== _this.$props.challenge_id;\n });\n }\n },\n methods: {\n loadData: function loadData() {\n var _this2 = this;\n\n _CTFd[\"default\"].fetch(\"/api/v1/challenges/\".concat(this.$props.challenge_id), {\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 _this2.challenge = response.data;\n _this2.selected_id = response.data.next_id;\n }\n });\n },\n loadChallenges: function loadChallenges() {\n var _this3 = this;\n\n _CTFd[\"default\"].fetch(\"/api/v1/challenges?view=admin\", {\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 _this3.challenges = response.data;\n }\n });\n },\n updateNext: function updateNext() {\n var _this4 = this;\n\n _CTFd[\"default\"].fetch(\"/api/v1/challenges/\".concat(this.$props.challenge_id), {\n method: \"PATCH\",\n credentials: \"same-origin\",\n headers: {\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\"\n },\n body: JSON.stringify({\n next_id: this.selected_id != \"null\" ? this.selected_id : null\n })\n }).then(function (response) {\n return response.json();\n }).then(function (data) {\n if (data.success) {\n _this4.loadData();\n\n _this4.loadChallenges();\n }\n });\n }\n },\n created: function created() {\n this.loadData();\n this.loadChallenges();\n }\n};\nexports[\"default\"] = _default;\n\n//# sourceURL=webpack:///./CTFd/themes/admin/assets/js/components/next/NextChallenge.vue?./node_modules/babel-loader/lib??ref--0!./node_modules/vue-loader/lib??vue-loader-options"); +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\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//\nvar _default = {\n props: {\n challenge_id: Number\n },\n data: function data() {\n return {\n challenge: null,\n challenges: [],\n selected_id: null\n };\n },\n computed: {\n updateAvailable: function updateAvailable() {\n if (this.challenge) {\n return this.selected_id != this.challenge.next_id;\n } else {\n return false;\n }\n },\n // Get all challenges besides the current one and current next\n otherChallenges: function otherChallenges() {\n var _this = this;\n\n return this.challenges.filter(function (challenge) {\n return challenge.id !== _this.$props.challenge_id;\n });\n }\n },\n methods: {\n loadData: function loadData() {\n var _this2 = this;\n\n _CTFd[\"default\"].fetch(\"/api/v1/challenges/\".concat(this.$props.challenge_id), {\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 _this2.challenge = response.data;\n _this2.selected_id = response.data.next_id;\n }\n });\n },\n loadChallenges: function loadChallenges() {\n var _this3 = this;\n\n _CTFd[\"default\"].fetch(\"/api/v1/challenges?view=admin\", {\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 _this3.challenges = response.data;\n }\n });\n },\n updateNext: function updateNext() {\n var _this4 = this;\n\n _CTFd[\"default\"].fetch(\"/api/v1/challenges/\".concat(this.$props.challenge_id), {\n method: \"PATCH\",\n credentials: \"same-origin\",\n headers: {\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\"\n },\n body: JSON.stringify({\n next_id: this.selected_id != \"null\" ? this.selected_id : null\n })\n }).then(function (response) {\n return response.json();\n }).then(function (data) {\n if (data.success) {\n _this4.loadData();\n\n _this4.loadChallenges();\n }\n });\n }\n },\n created: function created() {\n this.loadData();\n this.loadChallenges();\n }\n};\nexports[\"default\"] = _default;\n\n//# sourceURL=webpack:///./CTFd/themes/admin/assets/js/components/next/NextChallenge.vue?./node_modules/babel-loader/lib??ref--0!./node_modules/vue-loader/lib??vue-loader-options"); /***/ }), diff --git a/CTFd/themes/admin/static/js/components.min.js b/CTFd/themes/admin/static/js/components.min.js index 220ae52946..0a02c0d81c 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(""+_this.editForm+"").find("script").each(function(){eval((0,_jquery.default)(this).html())})},100)})})},updateFlag:function(e){var t=this,s=(0,_jquery.default)(e.target).serializeJSON(!0);_CTFd.default.fetch("/api/v1/flags/".concat(this.$props.flag_id),{method:"PATCH",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)})}},mounted:function(){this.flag_id&&this.loadFlag()},created:function(){this.flag_id&&this.loadFlag()}};exports.default=_default},"./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/flags/FlagList.vue?vue&type=script&lang=js&":function(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var n=l(s("./node_modules/jquery/dist/jquery.js")),i=l(s("./CTFd/themes/core/assets/js/CTFd.js")),a=l(s("./CTFd/themes/admin/assets/js/components/flags/FlagCreationForm.vue")),o=l(s("./CTFd/themes/admin/assets/js/components/flags/FlagEditForm.vue"));function l(e){return e&&e.__esModule?e:{default:e}}var d={components:{FlagCreationForm:a.default,FlagEditForm:o.default},props:{challenge_id:Number},data:function(){return{flags:[],editing_flag_id:null}},methods:{loadFlags:function(){var t=this;i.default.fetch("/api/v1/challenges/".concat(this.$props.challenge_id,"/flags"),{method:"GET",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()}).then(function(e){e.success&&(t.flags=e.data)})},refreshFlags:function(e){var t;switch(this.loadFlags(),e){case"FlagEditForm":t=this.$refs.FlagEditForm.$el,(0,n.default)(t).modal("hide");break;case"FlagCreationForm":t=this.$refs.FlagCreationForm.$el,(0,n.default)(t).modal("hide")}},addFlag:function(){var e=this.$refs.FlagCreationForm.$el;(0,n.default)(e).modal()},editFlag:function(e){this.editing_flag_id=e;var t=this.$refs.FlagEditForm.$el;(0,n.default)(t).modal()},deleteFlag:function(e){var t=this;confirm("Are you sure you'd like to delete this flag?")&&i.default.fetch("/api/v1/flags/".concat(e),{method:"DELETE"}).then(function(e){return e.json()}).then(function(e){e.success&&t.loadFlags()})}},created:function(){this.loadFlags()}};t.default=d},"./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/hints/HintCreationForm.vue?vue&type=script&lang=js&":function(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var n={name:"HintCreationForm",props:{challenge_id:Number,hints:Array},data:function(){return{cost:0,selectedHints:[]}},methods:{getCost:function(){return this.cost||0},getContent:function(){return this.$refs.content.value},submitHint:function(){var t=this,e={challenge_id:this.$props.challenge_id,content:this.getContent(),cost:this.getCost(),requirements:{prerequisites:this.selectedHints}};CTFd.fetch("/api/v1/hints",{method:"POST",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"},body:JSON.stringify(e)}).then(function(e){return e.json()}).then(function(e){e.success&&t.$emit("refreshHints",t.$options.name)})}}};t.default=n},"./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/hints/HintEditForm.vue?vue&type=script&lang=js&":function(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var n,i=(n=s("./CTFd/themes/core/assets/js/CTFd.js"))&&n.__esModule?n:{default:n},a=s("./CTFd/themes/admin/assets/js/styles.js");var o={name:"HintEditForm",props:{challenge_id:Number,hint_id:Number,hints:Array},data:function(){return{cost:0,content:null,selectedHints:[]}},computed:{otherHints:function(){var t=this;return this.hints.filter(function(e){return e.id!==t.$props.hint_id})}},watch:{hint_id:{immediate:!0,handler:function(e){null!==e&&this.loadHint()}}},methods:{loadHint:function(){var n=this;i.default.fetch("/api/v1/hints/".concat(this.$props.hint_id,"?preview=true"),{method:"GET",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()}).then(function(e){var t,s;e.success&&(s=e.data,n.cost=s.cost,n.content=s.content,n.selectedHints=(null===(t=s.requirements)||void 0===t?void 0:t.prerequisites)||[],n.$nextTick(function(){setTimeout(function(){var e=n.$refs.content;(0,a.bindMarkdownEditor)(e),e.mde.codemirror.getDoc().setValue(e.value),e.mde.codemirror.refresh()},100)}))})},getCost:function(){return this.cost||0},getContent:function(){return this.$refs.content.value},updateHint:function(){var t=this,e={challenge_id:this.$props.challenge_id,content:this.getContent(),cost:this.getCost(),requirements:{prerequisites:this.selectedHints}};i.default.fetch("/api/v1/hints/".concat(this.$props.hint_id),{method:"PATCH",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"},body:JSON.stringify(e)}).then(function(e){return e.json()}).then(function(e){e.success&&t.$emit("refreshHints",t.$options.name)})}},mounted:function(){this.hint_id&&this.loadHint()},created:function(){this.hint_id&&this.loadHint()}};t.default=o},"./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/hints/HintsList.vue?vue&type=script&lang=js&":function(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var n=s("./CTFd/themes/core/assets/js/ezq.js"),i=l(s("./CTFd/themes/core/assets/js/CTFd.js")),a=l(s("./CTFd/themes/admin/assets/js/components/hints/HintCreationForm.vue")),o=l(s("./CTFd/themes/admin/assets/js/components/hints/HintEditForm.vue"));function l(e){return e&&e.__esModule?e:{default:e}}var d={components:{HintCreationForm:a.default,HintEditForm:o.default},props:{challenge_id:Number},data:function(){return{hints:[],editing_hint_id:null}},methods:{loadHints:function(){var t=this;i.default.fetch("/api/v1/challenges/".concat(this.$props.challenge_id,"/hints"),{method:"GET",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()}).then(function(e){e.success&&(t.hints=e.data)})},addHint:function(){var e=this.$refs.HintCreationForm.$el;$(e).modal()},editHint:function(e){this.editing_hint_id=e;var t=this.$refs.HintEditForm.$el;$(t).modal()},refreshHints:function(e){var t;switch(this.loadHints(),e){case"HintCreationForm":t=this.$refs.HintCreationForm.$el,$(t).modal("hide");break;case"HintEditForm":t=this.$refs.HintEditForm.$el,$(t).modal("hide")}},deleteHint:function(e){var t=this;(0,n.ezQuery)({title:"Delete Hint",body:"Are you sure you want to delete this hint?",success:function(){i.default.fetch("/api/v1/hints/".concat(e),{method:"DELETE"}).then(function(e){return e.json()}).then(function(e){e.success&&t.loadHints()})}})}},created:function(){this.loadHints()}};t.default=d},"./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/next/NextChallenge.vue?vue&type=script&lang=js&":function(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var n,i=(n=s("./CTFd/themes/core/assets/js/CTFd.js"))&&n.__esModule?n:{default:n};var a={props:{challenge_id:Number},data:function(){return{challenge:null,challenges:[],selected_id:null}},computed:{updateAvailable:function(){return this.selected_id!=this.challenge.next_id},otherChallenges:function(){var t=this;return this.challenges.filter(function(e){return e.id!==t.$props.challenge_id})}},methods:{loadData:function(){var t=this;i.default.fetch("/api/v1/challenges/".concat(this.$props.challenge_id),{method:"GET",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()}).then(function(e){e.success&&(t.challenge=e.data,t.selected_id=e.data.next_id)})},loadChallenges:function(){var t=this;i.default.fetch("/api/v1/challenges?view=admin",{method:"GET",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()}).then(function(e){e.success&&(t.challenges=e.data)})},updateNext:function(){var t=this;i.default.fetch("/api/v1/challenges/".concat(this.$props.challenge_id),{method:"PATCH",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"},body:JSON.stringify({next_id:"null"!=this.selected_id?this.selected_id:null})}).then(function(e){return e.json()}).then(function(e){e.success&&(t.loadData(),t.loadChallenges())})}},created:function(){this.loadData(),this.loadChallenges()}};t.default=a},"./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/notifications/Notification.vue?vue&type=script&lang=js&":function(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var n=o(s("./CTFd/themes/core/assets/js/CTFd.js")),i=o(s("./node_modules/dayjs/dayjs.min.js")),a=o(s("./node_modules/highlight.js/lib/index.js"));function o(e){return e&&e.__esModule?e:{default:e}}var l={props:{id:Number,title:String,content:String,html:String,date:String},methods:{localDate:function(){return(0,i.default)(this.date).format("MMMM Do, h:mm:ss A")},deleteNotification:function(){var t=this;confirm("Are you sure you want to delete this notification?")&&n.default.api.delete_notification({notificationId:this.id}).then(function(e){e.success&&(t.$destroy(),t.$el.parentNode.removeChild(t.$el))})}},mounted:function(){this.$el.querySelectorAll("pre code").forEach(function(e){a.default.highlightBlock(e)})}};t.default=l},"./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/requirements/Requirements.vue?vue&type=script&lang=js&":function(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var n,i=(n=s("./CTFd/themes/core/assets/js/CTFd.js"))&&n.__esModule?n:{default:n};var a={props:{challenge_id:Number},data:function(){return{challenges:[],requirements:{},selectedRequirements:[],selectedAnonymize:!1}},computed:{newRequirements:function(){var e=this.requirements.prerequisites||[],t=this.requirements.anonymize||!1,s=JSON.stringify(e.sort())!==JSON.stringify(this.selectedRequirements.sort()),n=t!==this.selectedAnonymize;return s||n},requiredChallenges:function(){var t=this,s=this.requirements.prerequisites||[];return this.challenges.filter(function(e){return e.id!==t.$props.challenge_id&&s.includes(e.id)})},otherChallenges:function(){var t=this,s=this.requirements.prerequisites||[];return this.challenges.filter(function(e){return e.id!==t.$props.challenge_id&&!s.includes(e.id)})}},methods:{loadChallenges:function(){var t=this;i.default.fetch("/api/v1/challenges?view=admin",{method:"GET",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()}).then(function(e){e.success&&(t.challenges=e.data)})},getChallengeNameById:function(t){var e=this.challenges.find(function(e){return e.id===t});return e?e.name:""},loadRequirements:function(){var t=this;i.default.fetch("/api/v1/challenges/".concat(this.$props.challenge_id,"/requirements"),{method:"GET",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()}).then(function(e){e.success&&(t.requirements=e.data||{},t.selectedRequirements=t.requirements.prerequisites||[],t.selectedAnonymize=t.requirements.anonymize||!1)})},updateRequirements:function(){var t=this,e={requirements:{prerequisites:this.selectedRequirements}};this.selectedAnonymize&&(e.requirements.anonymize=!0),i.default.fetch("/api/v1/challenges/".concat(this.$props.challenge_id),{method:"PATCH",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"},body:JSON.stringify(e)}).then(function(e){return e.json()}).then(function(e){e.success&&t.loadRequirements()})}},created:function(){this.loadChallenges(),this.loadRequirements()}};t.default=a},"./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/tags/TagsList.vue?vue&type=script&lang=js&":function(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;i(s("./node_modules/jquery/dist/jquery.js"));var n=i(s("./CTFd/themes/core/assets/js/CTFd.js"));function i(e){return e&&e.__esModule?e:{default:e}}var a={props:{challenge_id:Number},data:function(){return{tags:[],tagValue:""}},methods:{loadTags:function(){var t=this;n.default.fetch("/api/v1/challenges/".concat(this.$props.challenge_id,"/tags"),{method:"GET",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()}).then(function(e){e.success&&(t.tags=e.data)})},addTag:function(){var t=this,e={value:this.tagValue,challenge:this.$props.challenge_id};n.default.api.post_tag_list({},e).then(function(e){e.success&&(t.tagValue="",t.loadTags())})},deleteTag:function(e){var t=this;n.default.api.delete_tag({tagId:e}).then(function(e){e.success&&t.loadTags()})}},created:function(){this.loadTags()}};t.default=a},"./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&":function(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var n,i=(n=s("./CTFd/themes/core/assets/js/CTFd.js"))&&n.__esModule?n:{default:n},a=s("./CTFd/themes/core/assets/js/ezq.js"),o=s("./CTFd/themes/core/assets/js/utils.js");var l={name:"UserAddForm",props:{team_id:Number},data:function(){return{searchedName:"",awaitingSearch:!1,emptyResults:!1,userResults:[],selectedResultIdx:0,selectedUsers:[]}},methods:{searchUsers:function(){var t=this;this.selectedResultIdx=0,""!=this.searchedName?i.default.fetch("/api/v1/users?view=admin&field=name&q=".concat(this.searchedName),{method:"GET",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()}).then(function(e){e.success&&(t.userResults=e.data.slice(0,10))}):this.userResults=[]},moveCursor:function(e){switch(e){case"up":this.selectedResultIdx&&--this.selectedResultIdx;break;case"down":this.selectedResultIdx
".concat(e,"

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!"),success:function(){t.handleRemoveUsersFromTeams().then(function(e){t.handleAddUsersRequest().then(function(e){window.location.reload()})})}})):this.handleAddUsersRequest().then(function(e){window.location.reload()})}},watch:{searchedName:function(){var e=this;!1===this.awaitingSearch&&setTimeout(function(){e.searchUsers(),e.awaitingSearch=!1},1e3),this.awaitingSearch=!0}}};t.default=l},"./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/topics/TopicsList.vue?vue&type=script&lang=js&":function(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var n,i=(n=s("./CTFd/themes/core/assets/js/CTFd.js"))&&n.__esModule?n:{default:n};var a={props:{challenge_id:Number},data:function(){return{topics:[],topicValue:"",searchedTopic:"",topicResults:[],selectedResultIdx:0,awaitingSearch:!1}},methods:{loadTopics:function(){var t=this;i.default.fetch("/api/v1/challenges/".concat(this.$props.challenge_id,"/topics"),{method:"GET",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()}).then(function(e){e.success&&(t.topics=e.data)})},searchTopics:function(){var t=this;this.selectedResultIdx=0,""!=this.topicValue?i.default.fetch("/api/v1/topics?field=value&q=".concat(this.topicValue),{method:"GET",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()}).then(function(e){e.success&&(t.topicResults=e.data.slice(0,10))}):this.topicResults=[]},addTopic:function(){var e,t=this,s={value:0===this.selectedResultIdx?this.topicValue:(e=this.selectedResultIdx-1,this.topicResults[e].value),challenge:this.$props.challenge_id,type:"challenge"};i.default.fetch("/api/v1/topics",{method:"POST",body:JSON.stringify(s)}).then(function(e){return e.json()}).then(function(e){e.success&&(t.topicValue="",t.loadTopics())})},deleteTopic:function(e){var t=this;i.default.fetch("/api/v1/topics?type=challenge&target_id=".concat(e),{method:"DELETE"}).then(function(e){return e.json()}).then(function(e){e.success&&t.loadTopics()})},moveCursor:function(e){switch(e){case"up":this.selectedResultIdx&&--this.selectedResultIdx;break;case"down":this.selectedResultIdx>>\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(""+_this.editForm+"").find("script").each(function(){eval((0,_jquery.default)(this).html())})},100)})})},updateFlag:function(e){var t=this,s=(0,_jquery.default)(e.target).serializeJSON(!0);_CTFd.default.fetch("/api/v1/flags/".concat(this.$props.flag_id),{method:"PATCH",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)})}},mounted:function(){this.flag_id&&this.loadFlag()},created:function(){this.flag_id&&this.loadFlag()}};exports.default=_default},"./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/flags/FlagList.vue?vue&type=script&lang=js&":function(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var n=l(s("./node_modules/jquery/dist/jquery.js")),i=l(s("./CTFd/themes/core/assets/js/CTFd.js")),a=l(s("./CTFd/themes/admin/assets/js/components/flags/FlagCreationForm.vue")),o=l(s("./CTFd/themes/admin/assets/js/components/flags/FlagEditForm.vue"));function l(e){return e&&e.__esModule?e:{default:e}}var d={components:{FlagCreationForm:a.default,FlagEditForm:o.default},props:{challenge_id:Number},data:function(){return{flags:[],editing_flag_id:null}},methods:{loadFlags:function(){var t=this;i.default.fetch("/api/v1/challenges/".concat(this.$props.challenge_id,"/flags"),{method:"GET",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()}).then(function(e){e.success&&(t.flags=e.data)})},refreshFlags:function(e){var t;switch(this.loadFlags(),e){case"FlagEditForm":t=this.$refs.FlagEditForm.$el,(0,n.default)(t).modal("hide");break;case"FlagCreationForm":t=this.$refs.FlagCreationForm.$el,(0,n.default)(t).modal("hide")}},addFlag:function(){var e=this.$refs.FlagCreationForm.$el;(0,n.default)(e).modal()},editFlag:function(e){this.editing_flag_id=e;var t=this.$refs.FlagEditForm.$el;(0,n.default)(t).modal()},deleteFlag:function(e){var t=this;confirm("Are you sure you'd like to delete this flag?")&&i.default.fetch("/api/v1/flags/".concat(e),{method:"DELETE"}).then(function(e){return e.json()}).then(function(e){e.success&&t.loadFlags()})}},created:function(){this.loadFlags()}};t.default=d},"./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/hints/HintCreationForm.vue?vue&type=script&lang=js&":function(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var n={name:"HintCreationForm",props:{challenge_id:Number,hints:Array},data:function(){return{cost:0,selectedHints:[]}},methods:{getCost:function(){return this.cost||0},getContent:function(){return this.$refs.content.value},submitHint:function(){var t=this,e={challenge_id:this.$props.challenge_id,content:this.getContent(),cost:this.getCost(),requirements:{prerequisites:this.selectedHints}};CTFd.fetch("/api/v1/hints",{method:"POST",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"},body:JSON.stringify(e)}).then(function(e){return e.json()}).then(function(e){e.success&&t.$emit("refreshHints",t.$options.name)})}}};t.default=n},"./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/hints/HintEditForm.vue?vue&type=script&lang=js&":function(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var n,i=(n=s("./CTFd/themes/core/assets/js/CTFd.js"))&&n.__esModule?n:{default:n},a=s("./CTFd/themes/admin/assets/js/styles.js");var o={name:"HintEditForm",props:{challenge_id:Number,hint_id:Number,hints:Array},data:function(){return{cost:0,content:null,selectedHints:[]}},computed:{otherHints:function(){var t=this;return this.hints.filter(function(e){return e.id!==t.$props.hint_id})}},watch:{hint_id:{immediate:!0,handler:function(e){null!==e&&this.loadHint()}}},methods:{loadHint:function(){var n=this;i.default.fetch("/api/v1/hints/".concat(this.$props.hint_id,"?preview=true"),{method:"GET",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()}).then(function(e){var t,s;e.success&&(s=e.data,n.cost=s.cost,n.content=s.content,n.selectedHints=(null===(t=s.requirements)||void 0===t?void 0:t.prerequisites)||[],n.$nextTick(function(){setTimeout(function(){var e=n.$refs.content;(0,a.bindMarkdownEditor)(e),e.mde.codemirror.getDoc().setValue(e.value),e.mde.codemirror.refresh()},100)}))})},getCost:function(){return this.cost||0},getContent:function(){return this.$refs.content.value},updateHint:function(){var t=this,e={challenge_id:this.$props.challenge_id,content:this.getContent(),cost:this.getCost(),requirements:{prerequisites:this.selectedHints}};i.default.fetch("/api/v1/hints/".concat(this.$props.hint_id),{method:"PATCH",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"},body:JSON.stringify(e)}).then(function(e){return e.json()}).then(function(e){e.success&&t.$emit("refreshHints",t.$options.name)})}},mounted:function(){this.hint_id&&this.loadHint()},created:function(){this.hint_id&&this.loadHint()}};t.default=o},"./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/hints/HintsList.vue?vue&type=script&lang=js&":function(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var n=s("./CTFd/themes/core/assets/js/ezq.js"),i=l(s("./CTFd/themes/core/assets/js/CTFd.js")),a=l(s("./CTFd/themes/admin/assets/js/components/hints/HintCreationForm.vue")),o=l(s("./CTFd/themes/admin/assets/js/components/hints/HintEditForm.vue"));function l(e){return e&&e.__esModule?e:{default:e}}var d={components:{HintCreationForm:a.default,HintEditForm:o.default},props:{challenge_id:Number},data:function(){return{hints:[],editing_hint_id:null}},methods:{loadHints:function(){var t=this;i.default.fetch("/api/v1/challenges/".concat(this.$props.challenge_id,"/hints"),{method:"GET",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()}).then(function(e){e.success&&(t.hints=e.data)})},addHint:function(){var e=this.$refs.HintCreationForm.$el;$(e).modal()},editHint:function(e){this.editing_hint_id=e;var t=this.$refs.HintEditForm.$el;$(t).modal()},refreshHints:function(e){var t;switch(this.loadHints(),e){case"HintCreationForm":t=this.$refs.HintCreationForm.$el,$(t).modal("hide");break;case"HintEditForm":t=this.$refs.HintEditForm.$el,$(t).modal("hide")}},deleteHint:function(e){var t=this;(0,n.ezQuery)({title:"Delete Hint",body:"Are you sure you want to delete this hint?",success:function(){i.default.fetch("/api/v1/hints/".concat(e),{method:"DELETE"}).then(function(e){return e.json()}).then(function(e){e.success&&t.loadHints()})}})}},created:function(){this.loadHints()}};t.default=d},"./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/next/NextChallenge.vue?vue&type=script&lang=js&":function(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var n,i=(n=s("./CTFd/themes/core/assets/js/CTFd.js"))&&n.__esModule?n:{default:n};var a={props:{challenge_id:Number},data:function(){return{challenge:null,challenges:[],selected_id:null}},computed:{updateAvailable:function(){return!!this.challenge&&this.selected_id!=this.challenge.next_id},otherChallenges:function(){var t=this;return this.challenges.filter(function(e){return e.id!==t.$props.challenge_id})}},methods:{loadData:function(){var t=this;i.default.fetch("/api/v1/challenges/".concat(this.$props.challenge_id),{method:"GET",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()}).then(function(e){e.success&&(t.challenge=e.data,t.selected_id=e.data.next_id)})},loadChallenges:function(){var t=this;i.default.fetch("/api/v1/challenges?view=admin",{method:"GET",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()}).then(function(e){e.success&&(t.challenges=e.data)})},updateNext:function(){var t=this;i.default.fetch("/api/v1/challenges/".concat(this.$props.challenge_id),{method:"PATCH",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"},body:JSON.stringify({next_id:"null"!=this.selected_id?this.selected_id:null})}).then(function(e){return e.json()}).then(function(e){e.success&&(t.loadData(),t.loadChallenges())})}},created:function(){this.loadData(),this.loadChallenges()}};t.default=a},"./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/notifications/Notification.vue?vue&type=script&lang=js&":function(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var n=o(s("./CTFd/themes/core/assets/js/CTFd.js")),i=o(s("./node_modules/dayjs/dayjs.min.js")),a=o(s("./node_modules/highlight.js/lib/index.js"));function o(e){return e&&e.__esModule?e:{default:e}}var l={props:{id:Number,title:String,content:String,html:String,date:String},methods:{localDate:function(){return(0,i.default)(this.date).format("MMMM Do, h:mm:ss A")},deleteNotification:function(){var t=this;confirm("Are you sure you want to delete this notification?")&&n.default.api.delete_notification({notificationId:this.id}).then(function(e){e.success&&(t.$destroy(),t.$el.parentNode.removeChild(t.$el))})}},mounted:function(){this.$el.querySelectorAll("pre code").forEach(function(e){a.default.highlightBlock(e)})}};t.default=l},"./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/requirements/Requirements.vue?vue&type=script&lang=js&":function(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var n,i=(n=s("./CTFd/themes/core/assets/js/CTFd.js"))&&n.__esModule?n:{default:n};var a={props:{challenge_id:Number},data:function(){return{challenges:[],requirements:{},selectedRequirements:[],selectedAnonymize:!1}},computed:{newRequirements:function(){var e=this.requirements.prerequisites||[],t=this.requirements.anonymize||!1,s=JSON.stringify(e.sort())!==JSON.stringify(this.selectedRequirements.sort()),n=t!==this.selectedAnonymize;return s||n},requiredChallenges:function(){var t=this,s=this.requirements.prerequisites||[];return this.challenges.filter(function(e){return e.id!==t.$props.challenge_id&&s.includes(e.id)})},otherChallenges:function(){var t=this,s=this.requirements.prerequisites||[];return this.challenges.filter(function(e){return e.id!==t.$props.challenge_id&&!s.includes(e.id)})}},methods:{loadChallenges:function(){var t=this;i.default.fetch("/api/v1/challenges?view=admin",{method:"GET",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()}).then(function(e){e.success&&(t.challenges=e.data)})},getChallengeNameById:function(t){var e=this.challenges.find(function(e){return e.id===t});return e?e.name:""},loadRequirements:function(){var t=this;i.default.fetch("/api/v1/challenges/".concat(this.$props.challenge_id,"/requirements"),{method:"GET",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()}).then(function(e){e.success&&(t.requirements=e.data||{},t.selectedRequirements=t.requirements.prerequisites||[],t.selectedAnonymize=t.requirements.anonymize||!1)})},updateRequirements:function(){var t=this,e={requirements:{prerequisites:this.selectedRequirements}};this.selectedAnonymize&&(e.requirements.anonymize=!0),i.default.fetch("/api/v1/challenges/".concat(this.$props.challenge_id),{method:"PATCH",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"},body:JSON.stringify(e)}).then(function(e){return e.json()}).then(function(e){e.success&&t.loadRequirements()})}},created:function(){this.loadChallenges(),this.loadRequirements()}};t.default=a},"./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/tags/TagsList.vue?vue&type=script&lang=js&":function(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;i(s("./node_modules/jquery/dist/jquery.js"));var n=i(s("./CTFd/themes/core/assets/js/CTFd.js"));function i(e){return e&&e.__esModule?e:{default:e}}var a={props:{challenge_id:Number},data:function(){return{tags:[],tagValue:""}},methods:{loadTags:function(){var t=this;n.default.fetch("/api/v1/challenges/".concat(this.$props.challenge_id,"/tags"),{method:"GET",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()}).then(function(e){e.success&&(t.tags=e.data)})},addTag:function(){var t=this,e={value:this.tagValue,challenge:this.$props.challenge_id};n.default.api.post_tag_list({},e).then(function(e){e.success&&(t.tagValue="",t.loadTags())})},deleteTag:function(e){var t=this;n.default.api.delete_tag({tagId:e}).then(function(e){e.success&&t.loadTags()})}},created:function(){this.loadTags()}};t.default=a},"./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&":function(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var n,i=(n=s("./CTFd/themes/core/assets/js/CTFd.js"))&&n.__esModule?n:{default:n},a=s("./CTFd/themes/core/assets/js/ezq.js"),o=s("./CTFd/themes/core/assets/js/utils.js");var l={name:"UserAddForm",props:{team_id:Number},data:function(){return{searchedName:"",awaitingSearch:!1,emptyResults:!1,userResults:[],selectedResultIdx:0,selectedUsers:[]}},methods:{searchUsers:function(){var t=this;this.selectedResultIdx=0,""!=this.searchedName?i.default.fetch("/api/v1/users?view=admin&field=name&q=".concat(this.searchedName),{method:"GET",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()}).then(function(e){e.success&&(t.userResults=e.data.slice(0,10))}):this.userResults=[]},moveCursor:function(e){switch(e){case"up":this.selectedResultIdx&&--this.selectedResultIdx;break;case"down":this.selectedResultIdx
".concat(e,"

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!"),success:function(){t.handleRemoveUsersFromTeams().then(function(e){t.handleAddUsersRequest().then(function(e){window.location.reload()})})}})):this.handleAddUsersRequest().then(function(e){window.location.reload()})}},watch:{searchedName:function(){var e=this;!1===this.awaitingSearch&&setTimeout(function(){e.searchUsers(),e.awaitingSearch=!1},1e3),this.awaitingSearch=!0}}};t.default=l},"./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/topics/TopicsList.vue?vue&type=script&lang=js&":function(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var n,i=(n=s("./CTFd/themes/core/assets/js/CTFd.js"))&&n.__esModule?n:{default:n};var a={props:{challenge_id:Number},data:function(){return{topics:[],topicValue:"",searchedTopic:"",topicResults:[],selectedResultIdx:0,awaitingSearch:!1}},methods:{loadTopics:function(){var t=this;i.default.fetch("/api/v1/challenges/".concat(this.$props.challenge_id,"/topics"),{method:"GET",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()}).then(function(e){e.success&&(t.topics=e.data)})},searchTopics:function(){var t=this;this.selectedResultIdx=0,""!=this.topicValue?i.default.fetch("/api/v1/topics?field=value&q=".concat(this.topicValue),{method:"GET",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()}).then(function(e){e.success&&(t.topicResults=e.data.slice(0,10))}):this.topicResults=[]},addTopic:function(){var e,t=this,s={value:0===this.selectedResultIdx?this.topicValue:(e=this.selectedResultIdx-1,this.topicResults[e].value),challenge:this.$props.challenge_id,type:"challenge"};i.default.fetch("/api/v1/topics",{method:"POST",body:JSON.stringify(s)}).then(function(e){return e.json()}).then(function(e){e.success&&(t.topicValue="",t.loadTopics())})},deleteTopic:function(e){var t=this;i.default.fetch("/api/v1/topics?type=challenge&target_id=".concat(e),{method:"DELETE"}).then(function(e){return e.json()}).then(function(e){e.success&&t.loadTopics()})},moveCursor:function(e){switch(e){case"up":this.selectedResultIdx&&--this.selectedResultIdx;break;case"down":this.selectedResultIdx {% block stylesheets %} {% endblock %} diff --git a/CTFd/themes/admin/templates/challenges/challenges.html b/CTFd/themes/admin/templates/challenges/challenges.html index 6b26a91b48..df7fc7edf1 100644 --- a/CTFd/themes/admin/templates/challenges/challenges.html +++ b/CTFd/themes/admin/templates/challenges/challenges.html @@ -65,7 +65,7 @@
-
+
@@ -77,10 +77,10 @@
- - - - + + + + @@ -93,10 +93,10 @@
- - - - + + + diff --git a/CTFd/themes/admin/templates/configs/settings.html b/CTFd/themes/admin/templates/configs/settings.html index cc074b2c81..876a4d2ad4 100644 --- a/CTFd/themes/admin/templates/configs/settings.html +++ b/CTFd/themes/admin/templates/configs/settings.html @@ -22,50 +22,49 @@
- + - - - - - This setting should generally be the same as Account Visibility to avoid conflicts. -
- + - - + - This setting should generally be the same as Score Visibility to avoid conflicts. + Score Visibility is a subset of Account Visibility. + This means that if accounts are visible to a user then score visibility will control whether they can see the score of that user. + If accounts are not visibile then score visibility has no effect.
diff --git a/CTFd/themes/admin/templates/modals/teams/addresses.html b/CTFd/themes/admin/templates/modals/teams/addresses.html index bbc22f58e0..2d12b59d4c 100644 --- a/CTFd/themes/admin/templates/modals/teams/addresses.html +++ b/CTFd/themes/admin/templates/modals/teams/addresses.html @@ -1,5 +1,5 @@
-
+
ID NameCategoryValueTypeStateCategoryValueTypeState
{{ challenge.id }} {{ challenge.name }}{{ challenge.category }}{{ challenge.value }}{{ challenge.type }} + {{ challenge.category }}{{ challenge.value }}{{ challenge.type }} {% set badge_state = 'badge-danger' if challenge.state == 'hidden' else 'badge-success' %} {{ challenge.state }}
diff --git a/CTFd/themes/admin/templates/modals/teams/create.html b/CTFd/themes/admin/templates/modals/teams/create.html index 7007a2b7be..b14b8368a9 100644 --- a/CTFd/themes/admin/templates/modals/teams/create.html +++ b/CTFd/themes/admin/templates/modals/teams/create.html @@ -3,41 +3,41 @@
{{ form.name.label }} - {{ form.name(class="form-control") }} + {{ form.name(class="form-control", autocomplete="off") }}
{{ form.email.label }} - {{ form.email(class="form-control") }} + {{ form.email(class="form-control", autocomplete="off") }}
{{ form.password.label }} - {{ form.password(class="form-control") }} + {{ form.password(class="form-control", autocomplete="off") }}
{{ form.website.label }} Optional - {{ form.website(class="form-control") }} + {{ form.website(class="form-control", autocomplete="off") }}
{{ form.affiliation.label }} Optional - {{ form.affiliation(class="form-control") }} + {{ form.affiliation(class="form-control", autocomplete="off") }}
{{ form.country.label }} Optional - {{ form.country(class="form-control custom-select") }} + {{ form.country(class="form-control custom-select", autocomplete="off") }}
{{ render_extra_fields(form.extra) }}
- {{ form.hidden(class="form-check-input") }} + {{ form.hidden(class="form-check-input", autocomplete="off") }} {{ form.hidden.label(class="form-check-label") }}
- {{ form.banned(class="form-check-input") }} + {{ form.banned(class="form-check-input", autocomplete="off") }} {{ form.banned.label(class="form-check-label") }}
diff --git a/CTFd/themes/admin/templates/modals/teams/edit.html b/CTFd/themes/admin/templates/modals/teams/edit.html index 3823cb9cb1..ae41a467a8 100644 --- a/CTFd/themes/admin/templates/modals/teams/edit.html +++ b/CTFd/themes/admin/templates/modals/teams/edit.html @@ -3,38 +3,38 @@
{{ form.name.label }} - {{ form.name(class="form-control") }} + {{ form.name(class="form-control", autocomplete="off") }}
{{ form.email.label }} - {{ form.email(class="form-control") }} + {{ form.email(class="form-control", autocomplete="off") }}
{{ form.password.label }} - {{ form.password(class="form-control") }} + {{ form.password(class="form-control", autocomplete="off") }}
{{ form.website.label }} - {{ form.website(class="form-control") }} + {{ form.website(class="form-control", autocomplete="off") }}
{{ form.affiliation.label }} - {{ form.affiliation(class="form-control") }} + {{ form.affiliation(class="form-control", autocomplete="off") }}
{{ form.country.label }} - {{ form.country(class="form-control custom-select") }} + {{ form.country(class="form-control custom-select", autocomplete="off") }}
{{ render_extra_fields(form.extra) }}
- {{ form.hidden(class="form-check-input") }} + {{ form.hidden(class="form-check-input", autocomplete="off") }} {{ form.hidden.label(class="form-check-label") }}
- {{ form.banned(class="form-check-input") }} + {{ form.banned(class="form-check-input", autocomplete="off") }} {{ form.banned.label(class="form-check-label") }}
diff --git a/CTFd/themes/admin/templates/modals/users/addresses.html b/CTFd/themes/admin/templates/modals/users/addresses.html index 4012e3a940..02b8f4205b 100644 --- a/CTFd/themes/admin/templates/modals/users/addresses.html +++ b/CTFd/themes/admin/templates/modals/users/addresses.html @@ -1,5 +1,5 @@
-
+
diff --git a/CTFd/themes/admin/templates/modals/users/create.html b/CTFd/themes/admin/templates/modals/users/create.html index 1c60d9e097..0a024a846c 100644 --- a/CTFd/themes/admin/templates/modals/users/create.html +++ b/CTFd/themes/admin/templates/modals/users/create.html @@ -3,30 +3,30 @@
{{ form.name.label }} - {{ form.name(class="form-control") }} + {{ form.name(class="form-control", autocomplete="off") }}
{{ form.email.label }} - {{ form.email(class="form-control") }} + {{ form.email(class="form-control", autocomplete="off") }}
{{ form.password.label }} - {{ form.password(class="form-control") }} + {{ form.password(class="form-control", autocomplete="off") }}
{{ form.website.label }} Optional - {{ form.website(class="form-control") }} + {{ form.website(class="form-control", autocomplete="off") }}
{{ form.affiliation.label }} Optional - {{ form.affiliation(class="form-control") }} + {{ form.affiliation(class="form-control", autocomplete="off") }}
{{ form.country.label }} Optional - {{ form.country(class="form-control custom-select") }} + {{ form.country(class="form-control custom-select", autocomplete="off") }}
{{ render_extra_fields(form.extra) }} @@ -36,15 +36,15 @@ {{ form.type(class="form-control form-inline custom-select", id="type-select") }}
- {{ form.verified(class="form-check-input") }} + {{ form.verified(class="form-check-input", autocomplete="off") }} {{ form.verified.label(class="form-check-label") }}
- {{ form.hidden(class="form-check-input") }} + {{ form.hidden(class="form-check-input", autocomplete="off") }} {{ form.hidden.label(class="form-check-label") }}
- {{ form.banned(class="form-check-input") }} + {{ form.banned(class="form-check-input", autocomplete="off") }} {{ form.banned.label(class="form-check-label") }}
@@ -52,7 +52,7 @@ {% if can_send_mail() %}
- {{ form.notify(class="form-check-input", id="notify") }} + {{ form.notify(class="form-check-input", id="notify", autocomplete="off") }} {{ form.notify.label(class="form-check-label") }}
diff --git a/CTFd/themes/admin/templates/modals/users/edit.html b/CTFd/themes/admin/templates/modals/users/edit.html index 6791506494..6149ff750c 100644 --- a/CTFd/themes/admin/templates/modals/users/edit.html +++ b/CTFd/themes/admin/templates/modals/users/edit.html @@ -3,27 +3,27 @@
{{ form.name.label }} - {{ form.name(class="form-control") }} + {{ form.name(class="form-control", autocomplete="off") }}
{{ form.email.label }} - {{ form.email(class="form-control") }} + {{ form.email(class="form-control", autocomplete="off") }}
{{ form.password.label }} - {{ form.password(class="form-control") }} + {{ form.password(class="form-control", autocomplete="off") }}
{{ form.website.label }} - {{ form.website(class="form-control") }} + {{ form.website(class="form-control", autocomplete="off") }}
{{ form.affiliation.label }} - {{ form.affiliation(class="form-control") }} + {{ form.affiliation(class="form-control", autocomplete="off") }}
{{ form.country.label }} - {{ form.country(class="form-control custom-select") }} + {{ form.country(class="form-control custom-select", autocomplete="off") }}
{{ render_extra_fields(form.extra) }} @@ -33,15 +33,15 @@ {{ form.type(class="form-control form-inline custom-select", id="type-select") }}
- {{ form.verified(class="form-check-input") }} + {{ form.verified(class="form-check-input", autocomplete="off") }} {{ form.verified.label(class="form-check-label") }}
- {{ form.hidden(class="form-check-input") }} + {{ form.hidden(class="form-check-input", autocomplete="off") }} {{ form.hidden.label(class="form-check-label") }}
- {{ form.banned(class="form-check-input") }} + {{ form.banned(class="form-check-input", autocomplete="off") }} {{ form.banned.label(class="form-check-label") }}
diff --git a/CTFd/themes/admin/templates/pages.html b/CTFd/themes/admin/templates/pages.html index bfbf47d592..b9bfdb4032 100644 --- a/CTFd/themes/admin/templates/pages.html +++ b/CTFd/themes/admin/templates/pages.html @@ -29,7 +29,7 @@

Pages
-
+

diff --git a/CTFd/themes/admin/templates/reset.html b/CTFd/themes/admin/templates/reset.html index 27ffb4fef4..835fc234d7 100644 --- a/CTFd/themes/admin/templates/reset.html +++ b/CTFd/themes/admin/templates/reset.html @@ -39,7 +39,7 @@

Reset

- {{ form.accounts(class="form-check-input") }} + {{ form.accounts(class="form-check-input", autocomplete="off") }} {{ form.accounts.label(class="form-check-label") }}
@@ -50,7 +50,7 @@

Reset

- {{ form.submissions(class="form-check-input") }} + {{ form.submissions(class="form-check-input", autocomplete="off") }} {{ form.submissions.label(class="form-check-label") }}
@@ -61,7 +61,7 @@

Reset

- {{ form.challenges(class="form-check-input") }} + {{ form.challenges(class="form-check-input", autocomplete="off") }} {{ form.challenges.label(class="form-check-label") }}
@@ -72,7 +72,7 @@

Reset

- {{ form.pages(class="form-check-input") }} + {{ form.pages(class="form-check-input", autocomplete="off") }} {{ form.pages.label(class="form-check-label") }}
@@ -83,7 +83,7 @@

Reset

- {{ form.notifications(class="form-check-input") }} + {{ form.notifications(class="form-check-input", autocomplete="off") }} {{ form.notifications.label(class="form-check-label") }}
diff --git a/CTFd/themes/admin/templates/scoreboard.html b/CTFd/themes/admin/templates/scoreboard.html index be801c47a3..53fdea9e5b 100644 --- a/CTFd/themes/admin/templates/scoreboard.html +++ b/CTFd/themes/admin/templates/scoreboard.html @@ -39,7 +39,7 @@

Scoreboard

{% endif %}
-
+
{% include "admin/scoreboard/standings.html" %} diff --git a/CTFd/themes/admin/templates/submissions.html b/CTFd/themes/admin/templates/submissions.html index 0425f5ad22..80d3c0075a 100644 --- a/CTFd/themes/admin/templates/submissions.html +++ b/CTFd/themes/admin/templates/submissions.html @@ -56,7 +56,7 @@
-
+
{% set mode = Configs.user_mode %}
diff --git a/CTFd/themes/admin/templates/teams/team.html b/CTFd/themes/admin/templates/teams/team.html index bc60ec1a89..cdba83c1be 100644 --- a/CTFd/themes/admin/templates/teams/team.html +++ b/CTFd/themes/admin/templates/teams/team.html @@ -250,7 +250,7 @@

-
+

Team Members

@@ -326,7 +326,7 @@

Solves

-
+
@@ -392,7 +392,7 @@

Fails

-
+
@@ -454,7 +454,7 @@

Awards

-
+
@@ -515,7 +515,7 @@

Missing

-
+
diff --git a/CTFd/themes/admin/templates/teams/teams.html b/CTFd/themes/admin/templates/teams/teams.html index 70485074ee..87c7c688a1 100644 --- a/CTFd/themes/admin/templates/teams/teams.html +++ b/CTFd/themes/admin/templates/teams/teams.html @@ -64,7 +64,7 @@
-
+
diff --git a/CTFd/themes/admin/templates/users/user.html b/CTFd/themes/admin/templates/users/user.html index bb9dc3919f..bebe7658ae 100644 --- a/CTFd/themes/admin/templates/users/user.html +++ b/CTFd/themes/admin/templates/users/user.html @@ -212,7 +212,7 @@

Solves

-
+
@@ -272,7 +272,7 @@

Fails

-
+
@@ -330,7 +330,7 @@

Awards

-
+
@@ -385,7 +385,7 @@

Missing

-
+
diff --git a/CTFd/themes/admin/templates/users/users.html b/CTFd/themes/admin/templates/users/users.html index 28cfbdea83..83a8d29b3f 100644 --- a/CTFd/themes/admin/templates/users/users.html +++ b/CTFd/themes/admin/templates/users/users.html @@ -64,18 +64,18 @@
-
+
- + @@ -88,7 +88,7 @@
diff --git a/CTFd/themes/core/templates/base.html b/CTFd/themes/core/templates/base.html index 56236f2fb1..bb6b43c903 100644 --- a/CTFd/themes/core/templates/base.html +++ b/CTFd/themes/core/templates/base.html @@ -18,10 +18,10 @@ 'csrfNonce': "{{ Session.nonce }}", 'userMode': "{{ Configs.user_mode }}", 'userId': {{ Session.id }}, - 'userName': "{{ User.name }}", - 'userEmail': "{{ User.email }}", + 'userName': {{ User.name | tojson }}, + 'userEmail': {{ User.email | tojson }}, 'teamId': {{ Team.id | tojson }}, - 'teamName': "{{ Team.name }}", + 'teamName': {{ Team.name | tojson }}, 'start': {{ Configs.start | tojson }}, 'end': {{ Configs.end | tojson }}, 'theme_settings': {{ Configs.theme_settings | tojson }} diff --git a/CTFd/themes/core/templates/components/navbar.html b/CTFd/themes/core/templates/components/navbar.html index c2a1f580ae..1a5f045380 100644 --- a/CTFd/themes/core/templates/components/navbar.html +++ b/CTFd/themes/core/templates/components/navbar.html @@ -30,7 +30,7 @@ {% endif %} {% endif %} - {% if Configs.score_visibility != 'admins' %} + {% if Configs.account_visibility != 'admins' and Configs.score_visibility != 'admins' %} diff --git a/CTFd/utils/challenges/__init__.py b/CTFd/utils/challenges/__init__.py new file mode 100644 index 0000000000..83a8b0a991 --- /dev/null +++ b/CTFd/utils/challenges/__init__.py @@ -0,0 +1,126 @@ +import datetime +from collections import namedtuple + +from sqlalchemy import func as sa_func +from sqlalchemy.sql import and_, false, true + +from CTFd.cache import cache +from CTFd.models import Challenges, Solves, Users, db +from CTFd.schemas.tags import TagSchema +from CTFd.utils import get_config +from CTFd.utils.dates import isoformat, unix_time_to_utc +from CTFd.utils.helpers.models import build_model_filters +from CTFd.utils.modes import generate_account_url, get_model + +Challenge = namedtuple( + "Challenge", ["id", "type", "name", "value", "category", "tags", "requirements"] +) + + +@cache.memoize(timeout=60) +def get_all_challenges(admin=False, field=None, q=None, **query_args): + filters = build_model_filters(model=Challenges, query=q, field=field) + chal_q = Challenges.query + # Admins can see hidden and locked challenges in the admin view + if admin is False: + chal_q = chal_q.filter( + and_(Challenges.state != "hidden", Challenges.state != "locked") + ) + chal_q = ( + chal_q.filter_by(**query_args) + .filter(*filters) + .order_by(Challenges.value, Challenges.id) + ) + tag_schema = TagSchema(view="user", many=True) + + results = [] + for c in chal_q: + ct = Challenge( + id=c.id, + type=c.type, + name=c.name, + value=c.value, + category=c.category, + requirements=c.requirements, + tags=tag_schema.dump(c.tags).data, + ) + results.append(ct) + return results + + +@cache.memoize(timeout=60) +def get_solves_for_challenge_id(challenge_id, freeze=False): + Model = get_model() + # Note that we specifically query for the Solves.account.name + # attribute here because it is faster than having SQLAlchemy + # query for the attribute directly and it's unknown what the + # affects of changing the relationship lazy attribute would be + solves = ( + Solves.query.add_columns(Model.name.label("account_name")) + .join(Model, Solves.account_id == Model.id) + .filter( + Solves.challenge_id == challenge_id, + Model.banned == False, + Model.hidden == False, + ) + .order_by(Solves.date.asc()) + ) + if freeze: + freeze_time = get_config("freeze") + if freeze_time: + dt = datetime.datetime.utcfromtimestamp(freeze_time) + solves = solves.filter(Solves.date < dt) + results = [] + + for solve in solves: + # Seperate out the account name and the Solve object from the SQLAlchemy tuple + solve, account_name = solve + results.append( + { + "account_id": solve.account_id, + "name": account_name, + "date": isoformat(solve.date), + "account_url": generate_account_url(account_id=solve.account_id), + } + ) + return results + + +@cache.memoize(timeout=60) +def get_solve_ids_for_user_id(user_id): + user = Users.query.filter_by(id=user_id).first() + solve_ids = ( + Solves.query.with_entities(Solves.challenge_id) + .filter(Solves.account_id == user.account_id) + .all() + ) + solve_ids = {value for value, in solve_ids} + return solve_ids + + +@cache.memoize(timeout=60) +def get_solve_counts_for_challenges(challenge_id=None, admin=False): + if challenge_id is None: + challenge_id_filter = () + else: + challenge_id_filter = (Solves.challenge_id == challenge_id,) + AccountModel = get_model() + freeze = get_config("freeze") + if freeze and not admin: + freeze_cond = Solves.date < unix_time_to_utc(freeze) + else: + freeze_cond = true() + exclude_solves_cond = and_( + AccountModel.banned == false(), AccountModel.hidden == false(), + ) + solves_q = ( + db.session.query(Solves.challenge_id, sa_func.count(Solves.challenge_id),) + .join(AccountModel) + .filter(*challenge_id_filter, freeze_cond, exclude_solves_cond) + .group_by(Solves.challenge_id) + ) + + solve_counts = {} + for chal_id, solve_count in solves_q: + solve_counts[chal_id] = solve_count + return solve_counts diff --git a/CTFd/utils/config/__init__.py b/CTFd/utils/config/__init__.py index fd791bb994..81b0e23cf9 100644 --- a/CTFd/utils/config/__init__.py +++ b/CTFd/utils/config/__init__.py @@ -60,6 +60,9 @@ def can_send_mail(): def get_mail_provider(): + mail_provider = app.config.get("MAIL_PROVIDER") + if mail_provider: + return mail_provider if get_config("mail_server") and get_config("mail_port"): return "smtp" if get_config("mailgun_api_key") and get_config("mailgun_base_url"): diff --git a/CTFd/utils/decorators/__init__.py b/CTFd/utils/decorators/__init__.py index 4824797ca0..f5cf1d1bd7 100644 --- a/CTFd/utils/decorators/__init__.py +++ b/CTFd/utils/decorators/__init__.py @@ -29,8 +29,11 @@ def during_ctf_time_only_wrapper(*args, **kwargs): error = "{} has ended".format(config.ctf_name()) abort(403, description=error) if ctf_started() is False: - error = "{} has not started yet".format(config.ctf_name()) - abort(403, description=error) + if is_teams_mode() and get_current_team() is None: + return redirect(url_for("teams.private", next=request.full_path)) + else: + error = "{} has not started yet".format(config.ctf_name()) + abort(403, description=error) return during_ctf_time_only_wrapper diff --git a/CTFd/utils/email/__init__.py b/CTFd/utils/email/__init__.py index aa8dfe330a..9a2fba215e 100644 --- a/CTFd/utils/email/__init__.py +++ b/CTFd/utils/email/__init__.py @@ -2,10 +2,13 @@ from CTFd.utils import get_config from CTFd.utils.config import get_mail_provider -from CTFd.utils.email import mailgun, smtp +from CTFd.utils.email.providers.mailgun import MailgunEmailProvider +from CTFd.utils.email.providers.smtp import SMTPEmailProvider from CTFd.utils.formatters import safe_format from CTFd.utils.security.signing import serialize +PROVIDERS = {"smtp": SMTPEmailProvider, "mailgun": MailgunEmailProvider} + DEFAULT_VERIFICATION_EMAIL_SUBJECT = "Confirm your account for {ctf_name}" DEFAULT_VERIFICATION_EMAIL_BODY = ( "Welcome to {ctf_name}!\n\n" @@ -42,11 +45,10 @@ def sendmail(addr, text, subject="Message from {ctf_name}"): subject = safe_format(subject, ctf_name=get_config("ctf_name")) provider = get_mail_provider() - if provider == "smtp": - return smtp.sendmail(addr, text, subject) - if provider == "mailgun": - return mailgun.sendmail(addr, text, subject) - return False, "No mail settings configured" + EmailProvider = PROVIDERS.get(provider) + if EmailProvider is None: + return False, "No mail settings configured" + return EmailProvider.sendmail(addr, text, subject) def password_change_alert(email): diff --git a/CTFd/utils/email/mailgun.py b/CTFd/utils/email/mailgun.py index 93a3ee8b07..beb351fc31 100644 --- a/CTFd/utils/email/mailgun.py +++ b/CTFd/utils/email/mailgun.py @@ -1,40 +1,8 @@ -from email.utils import formataddr - -import requests - -from CTFd.utils import get_app_config, get_config +from CTFd.utils.email.providers.mailgun import MailgunEmailProvider def sendmail(addr, text, subject): - ctf_name = get_config("ctf_name") - mailfrom_addr = get_config("mailfrom_addr") or get_app_config("MAILFROM_ADDR") - mailfrom_addr = formataddr((ctf_name, mailfrom_addr)) - - mailgun_base_url = get_config("mailgun_base_url") or get_app_config( - "MAILGUN_BASE_URL" + print( + "CTFd.utils.email.mailgun.sendmail will raise an exception in a future minor release of CTFd and then be removed in CTFd v4.0" ) - mailgun_api_key = get_config("mailgun_api_key") or get_app_config("MAILGUN_API_KEY") - try: - r = requests.post( - mailgun_base_url + "/messages", - auth=("api", mailgun_api_key), - data={ - "from": mailfrom_addr, - "to": [addr], - "subject": subject, - "text": text, - }, - timeout=1.0, - ) - except requests.RequestException as e: - return ( - False, - "{error} exception occured while handling your request".format( - error=type(e).__name__ - ), - ) - - if r.status_code == 200: - return True, "Email sent" - else: - return False, "Mailgun settings are incorrect" + return MailgunEmailProvider.sendmail(addr, text, subject) diff --git a/CTFd/utils/email/providers/__init__.py b/CTFd/utils/email/providers/__init__.py new file mode 100644 index 0000000000..e5bf7094d0 --- /dev/null +++ b/CTFd/utils/email/providers/__init__.py @@ -0,0 +1,4 @@ +class EmailProvider: + @staticmethod + def sendmail(addr, text, subject): + raise NotImplementedError diff --git a/CTFd/utils/email/providers/mailgun.py b/CTFd/utils/email/providers/mailgun.py new file mode 100644 index 0000000000..514c1f3ceb --- /dev/null +++ b/CTFd/utils/email/providers/mailgun.py @@ -0,0 +1,45 @@ +from email.utils import formataddr + +import requests + +from CTFd.utils import get_app_config, get_config +from CTFd.utils.email.providers import EmailProvider + + +class MailgunEmailProvider(EmailProvider): + @staticmethod + def sendmail(addr, text, subject): + ctf_name = get_config("ctf_name") + mailfrom_addr = get_config("mailfrom_addr") or get_app_config("MAILFROM_ADDR") + mailfrom_addr = formataddr((ctf_name, mailfrom_addr)) + + mailgun_base_url = get_config("mailgun_base_url") or get_app_config( + "MAILGUN_BASE_URL" + ) + mailgun_api_key = get_config("mailgun_api_key") or get_app_config( + "MAILGUN_API_KEY" + ) + try: + r = requests.post( + mailgun_base_url + "/messages", + auth=("api", mailgun_api_key), + data={ + "from": mailfrom_addr, + "to": [addr], + "subject": subject, + "text": text, + }, + timeout=1.0, + ) + except requests.RequestException as e: + return ( + False, + "{error} exception occured while handling your request".format( + error=type(e).__name__ + ), + ) + + if r.status_code == 200: + return True, "Email sent" + else: + return False, "Mailgun settings are incorrect" diff --git a/CTFd/utils/email/providers/smtp.py b/CTFd/utils/email/providers/smtp.py new file mode 100644 index 0000000000..07e31afa81 --- /dev/null +++ b/CTFd/utils/email/providers/smtp.py @@ -0,0 +1,79 @@ +import smtplib +from email.message import EmailMessage +from email.utils import formataddr +from socket import timeout + +from CTFd.utils import get_app_config, get_config +from CTFd.utils.email.providers import EmailProvider + + +class SMTPEmailProvider(EmailProvider): + @staticmethod + def sendmail(addr, text, subject): + ctf_name = get_config("ctf_name") + mailfrom_addr = get_config("mailfrom_addr") or get_app_config("MAILFROM_ADDR") + mailfrom_addr = formataddr((ctf_name, mailfrom_addr)) + + data = { + "host": get_config("mail_server") or get_app_config("MAIL_SERVER"), + "port": int(get_config("mail_port") or get_app_config("MAIL_PORT")), + } + username = get_config("mail_username") or get_app_config("MAIL_USERNAME") + password = get_config("mail_password") or get_app_config("MAIL_PASSWORD") + TLS = get_config("mail_tls") or get_app_config("MAIL_TLS") + SSL = get_config("mail_ssl") or get_app_config("MAIL_SSL") + auth = get_config("mail_useauth") or get_app_config("MAIL_USEAUTH") + + if username: + data["username"] = username + if password: + data["password"] = password + if TLS: + data["TLS"] = TLS + if SSL: + data["SSL"] = SSL + if auth: + data["auth"] = auth + + try: + smtp = get_smtp(**data) + + msg = EmailMessage() + msg.set_content(text) + + msg["Subject"] = subject + msg["From"] = mailfrom_addr + msg["To"] = addr + + # Check whether we are using an admin-defined SMTP server + custom_smtp = bool(get_config("mail_server")) + + # We should only consider the MAILSENDER_ADDR value on servers defined in config + if custom_smtp: + smtp.send_message(msg) + else: + mailsender_addr = get_app_config("MAILSENDER_ADDR") + smtp.send_message(msg, from_addr=mailsender_addr) + + smtp.quit() + return True, "Email sent" + except smtplib.SMTPException as e: + return False, str(e) + except timeout: + return False, "SMTP server connection timed out" + except Exception as e: + return False, str(e) + + +def get_smtp(host, port, username=None, password=None, TLS=None, SSL=None, auth=None): + if SSL is None: + smtp = smtplib.SMTP(host, port, timeout=3) + else: + smtp = smtplib.SMTP_SSL(host, port, timeout=3) + + if TLS: + smtp.starttls() + + if auth: + smtp.login(username, password) + return smtp diff --git a/CTFd/utils/email/smtp.py b/CTFd/utils/email/smtp.py index 66fadaa8df..a8eed1ec18 100644 --- a/CTFd/utils/email/smtp.py +++ b/CTFd/utils/email/smtp.py @@ -1,76 +1,8 @@ -import smtplib -from email.message import EmailMessage -from email.utils import formataddr -from socket import timeout - -from CTFd.utils import get_app_config, get_config - - -def get_smtp(host, port, username=None, password=None, TLS=None, SSL=None, auth=None): - if SSL is None: - smtp = smtplib.SMTP(host, port, timeout=3) - else: - smtp = smtplib.SMTP_SSL(host, port, timeout=3) - - if TLS: - smtp.starttls() - - if auth: - smtp.login(username, password) - return smtp +from CTFd.utils.email.providers.smtp import SMTPEmailProvider def sendmail(addr, text, subject): - ctf_name = get_config("ctf_name") - mailfrom_addr = get_config("mailfrom_addr") or get_app_config("MAILFROM_ADDR") - mailfrom_addr = formataddr((ctf_name, mailfrom_addr)) - - data = { - "host": get_config("mail_server") or get_app_config("MAIL_SERVER"), - "port": int(get_config("mail_port") or get_app_config("MAIL_PORT")), - } - username = get_config("mail_username") or get_app_config("MAIL_USERNAME") - password = get_config("mail_password") or get_app_config("MAIL_PASSWORD") - TLS = get_config("mail_tls") or get_app_config("MAIL_TLS") - SSL = get_config("mail_ssl") or get_app_config("MAIL_SSL") - auth = get_config("mail_useauth") or get_app_config("MAIL_USEAUTH") - - if username: - data["username"] = username - if password: - data["password"] = password - if TLS: - data["TLS"] = TLS - if SSL: - data["SSL"] = SSL - if auth: - data["auth"] = auth - - try: - smtp = get_smtp(**data) - - msg = EmailMessage() - msg.set_content(text) - - msg["Subject"] = subject - msg["From"] = mailfrom_addr - msg["To"] = addr - - # Check whether we are using an admin-defined SMTP server - custom_smtp = bool(get_config("mail_server")) - - # We should only consider the MAILSENDER_ADDR value on servers defined in config - if custom_smtp: - smtp.send_message(msg) - else: - mailsender_addr = get_app_config("MAILSENDER_ADDR") - smtp.send_message(msg, from_addr=mailsender_addr) - - smtp.quit() - return True, "Email sent" - except smtplib.SMTPException as e: - return False, str(e) - except timeout: - return False, "SMTP server connection timed out" - except Exception as e: - return False, str(e) + print( + "CTFd.utils.email.smtp.sendmail will raise an exception in a future minor release of CTFd and then be removed in CTFd v4.0" + ) + return SMTPEmailProvider.sendmail(addr, text, subject) diff --git a/CTFd/utils/exports/__init__.py b/CTFd/utils/exports/__init__.py index 928caae7bd..784b85e484 100644 --- a/CTFd/utils/exports/__init__.py +++ b/CTFd/utils/exports/__init__.py @@ -30,6 +30,7 @@ from CTFd.utils.migrations import ( create_database, drop_database, + get_available_revisions, get_current_revision, stamp_latest_revision, ) @@ -195,6 +196,15 @@ def import_ctf(backup, erase=True): mysql = get_app_config("SQLALCHEMY_DATABASE_URI").startswith("mysql") mariadb = is_database_mariadb() + # Only import if we can actually make it to the target migration + if sqlite is False and alembic_version not in get_available_revisions(): + set_import_error( + "Exception: The target migration in this backup is not available in this version of CTFd." + ) + raise Exception( + "The target migration in this backup is not available in this version of CTFd." + ) + if erase: set_import_status("erasing") # Clear out existing connections to release any locks diff --git a/CTFd/utils/initialization/__init__.py b/CTFd/utils/initialization/__init__.py index a7ac22f1e5..8e0c376c8c 100644 --- a/CTFd/utils/initialization/__init__.py +++ b/CTFd/utils/initialization/__init__.py @@ -192,12 +192,18 @@ def inject_theme(endpoint, values): @app.before_request def needs_setup(): + if import_in_progress(): + if request.endpoint == "admin.import_ctf": + return + else: + return "Import currently in progress", 403 if is_setup() is False: if request.endpoint in ( "views.setup", "views.integrations", "views.themes", "views.files", + "views.healthcheck", ): return else: @@ -212,7 +218,7 @@ def tracker(): if request.endpoint == "admin.import_ctf": return else: - abort(403, description="Import currently in progress") + return "Import currently in progress", 403 if authed(): user_ips = get_current_user_recent_ips() diff --git a/CTFd/utils/migrations/__init__.py b/CTFd/utils/migrations/__init__.py index a5aab82a49..4202e5e413 100644 --- a/CTFd/utils/migrations/__init__.py +++ b/CTFd/utils/migrations/__init__.py @@ -1,4 +1,6 @@ import os +import re +from pathlib import Path from alembic.migration import MigrationContext from flask import current_app as app @@ -48,3 +50,13 @@ def stamp_latest_revision(): # Get proper migrations directory regardless of cwd directory = os.path.join(os.path.dirname(app.root_path), "migrations") stamp(directory=directory) + + +def get_available_revisions(): + revisions = [] + directory = Path(os.path.dirname(app.root_path), "migrations", "versions") + for f in directory.glob("*.py"): + with f.open() as migration: + revision = re.search(r'revision = "(.*?)"', migration.read()).group(1) + revisions.append(revision) + return revisions diff --git a/CTFd/utils/scores/__init__.py b/CTFd/utils/scores/__init__.py index 1de2e33504..d571c41fec 100644 --- a/CTFd/utils/scores/__init__.py +++ b/CTFd/utils/scores/__init__.py @@ -91,7 +91,11 @@ def get_standings(count=None, admin=False, fields=None): *fields, ) .join(sumscores, Model.id == sumscores.columns.account_id) - .order_by(sumscores.columns.score.desc(), sumscores.columns.id) + .order_by( + sumscores.columns.score.desc(), + sumscores.columns.date.asc(), + sumscores.columns.id.asc(), + ) ) else: standings_query = ( @@ -104,7 +108,11 @@ def get_standings(count=None, admin=False, fields=None): ) .join(sumscores, Model.id == sumscores.columns.account_id) .filter(Model.banned == False, Model.hidden == False) - .order_by(sumscores.columns.score.desc(), sumscores.columns.id) + .order_by( + sumscores.columns.score.desc(), + sumscores.columns.date.asc(), + sumscores.columns.id.asc(), + ) ) """ @@ -175,7 +183,11 @@ def get_team_standings(count=None, admin=False, fields=None): *fields, ) .join(sumscores, Teams.id == sumscores.columns.team_id) - .order_by(sumscores.columns.score.desc(), sumscores.columns.id) + .order_by( + sumscores.columns.score.desc(), + sumscores.columns.date.asc(), + sumscores.columns.id.asc(), + ) ) else: standings_query = ( @@ -189,7 +201,11 @@ def get_team_standings(count=None, admin=False, fields=None): .join(sumscores, Teams.id == sumscores.columns.team_id) .filter(Teams.banned == False) .filter(Teams.hidden == False) - .order_by(sumscores.columns.score.desc(), sumscores.columns.id) + .order_by( + sumscores.columns.score.desc(), + sumscores.columns.date.asc(), + sumscores.columns.id.asc(), + ) ) if count is None: @@ -258,7 +274,11 @@ def get_user_standings(count=None, admin=False, fields=None): *fields, ) .join(sumscores, Users.id == sumscores.columns.user_id) - .order_by(sumscores.columns.score.desc(), sumscores.columns.id) + .order_by( + sumscores.columns.score.desc(), + sumscores.columns.date.asc(), + sumscores.columns.id.asc(), + ) ) else: standings_query = ( @@ -272,7 +292,11 @@ def get_user_standings(count=None, admin=False, fields=None): ) .join(sumscores, Users.id == sumscores.columns.user_id) .filter(Users.banned == False, Users.hidden == False) - .order_by(sumscores.columns.score.desc(), sumscores.columns.id) + .order_by( + sumscores.columns.score.desc(), + sumscores.columns.date.asc(), + sumscores.columns.id.asc(), + ) ) if count is None: diff --git a/CTFd/utils/uploads/uploaders.py b/CTFd/utils/uploads/uploaders.py index d1be40905d..1b90c201c4 100644 --- a/CTFd/utils/uploads/uploaders.py +++ b/CTFd/utils/uploads/uploaders.py @@ -92,7 +92,7 @@ def _get_s3_connection(self): aws_access_key_id=access_key, aws_secret_access_key=secret_key, endpoint_url=endpoint, - region_name=region + region_name=region, ) return client diff --git a/Makefile b/Makefile index f7da0961e4..182dc5e5b0 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,8 @@ format: prettier --write '**/*.md' test: - pytest -rf --cov=CTFd --cov-context=test --cov-report=xml \ + pytest -rf --cov=CTFd --cov-context=test --cov-report=xml:reports/coverage/pytest.xml \ + --junitxml=reports/tests/pytest.xml \ --ignore-glob="**/node_modules/" \ --ignore=node_modules/ \ -W ignore::sqlalchemy.exc.SADeprecationWarning \ diff --git a/development.txt b/development.txt index 13b3310579..9448700259 100644 --- a/development.txt +++ b/development.txt @@ -6,7 +6,6 @@ coverage==5.1 flake8==3.8.2 freezegun==0.3.15 psycopg2-binary==2.8.6 -codecov==2.1.7 moto==1.3.16 bandit==1.6.2 flask_profiler==1.8.1 diff --git a/migrations/versions/46a278193a94_enable_millisecond_precision_in_mysql_.py b/migrations/versions/46a278193a94_enable_millisecond_precision_in_mysql_.py new file mode 100644 index 0000000000..c0fd0c02db --- /dev/null +++ b/migrations/versions/46a278193a94_enable_millisecond_precision_in_mysql_.py @@ -0,0 +1,46 @@ +"""Enable millisecond precision in MySQL datetime + +Revision ID: 46a278193a94 +Revises: 4d3c1b59d011 +Create Date: 2022-11-01 23:27:44.620893 + +""" +from alembic import op +from sqlalchemy.dialects import mysql + + +# revision identifiers, used by Alembic. +revision = "46a278193a94" +down_revision = "4d3c1b59d011" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + url = str(bind.engine.url) + if url.startswith("mysql"): + get_columns = "SELECT `TABLE_NAME`, `COLUMN_NAME` FROM `information_schema`.`COLUMNS` WHERE `table_schema`=DATABASE() AND `DATA_TYPE`='datetime' AND `COLUMN_TYPE`='datetime';" + conn = op.get_bind() + columns = conn.execute(get_columns).fetchall() + for table_name, column_name in columns: + op.alter_column( + table_name=table_name, + column_name=column_name, + type_=mysql.DATETIME(fsp=6), + ) + + +def downgrade(): + bind = op.get_bind() + url = str(bind.engine.url) + if url.startswith("mysql"): + get_columns = "SELECT `TABLE_NAME`, `COLUMN_NAME` FROM `information_schema`.`COLUMNS` WHERE `table_schema`=DATABASE() AND `DATA_TYPE`='datetime' AND `COLUMN_TYPE`='datetime(6)';" + conn = op.get_bind() + columns = conn.execute(get_columns).fetchall() + for table_name, column_name in columns: + op.alter_column( + table_name=table_name, + column_name=column_name, + type_=mysql.DATETIME(fsp=0), + ) diff --git a/package.json b/package.json index 15a708574b..3f5364c573 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ctfd", - "version": "3.5.0", + "version": "3.5.1", "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": { @@ -36,7 +36,7 @@ "easymde": "^2.10.1", "echarts": "^4.8.0", "eslint": "~5.12.0", - "event-source-polyfill": "~1.0.7", + "event-source-polyfill": "1.0.19", "file-loader": "~3.0.1", "highlight.js": "^10.4.1", "howler": "~2.1.2", diff --git a/populate.py b/populate.py index af669a659e..c54450d9c9 100644 --- a/populate.py +++ b/populate.py @@ -7,7 +7,7 @@ import argparse from CTFd import create_app -from CTFd.cache import clear_config, clear_standings, clear_pages +from CTFd.cache import clear_challenges, clear_config, clear_standings, clear_pages from CTFd.models import ( Users, Teams, @@ -352,4 +352,5 @@ def random_chance(): clear_config() clear_standings() + clear_challenges() clear_pages() diff --git a/requirements.in b/requirements.in index d1d4b6a937..88e6ca4c4e 100644 --- a/requirements.in +++ b/requirements.in @@ -8,15 +8,15 @@ Flask-Script==2.0.6 SQLAlchemy==1.3.17 SQLAlchemy-Utils==0.36.6 passlib==1.7.4 -bcrypt==3.2.2 +bcrypt==4.0.1 itsdangerous==1.1.0 -requests==2.27.1 +requests==2.28.1 PyMySQL==0.9.3 gunicorn==20.1.0 dataset==1.3.1 -cmarkgfm==0.8.0 -redis==3.5.2 -gevent==21.12.0 +cmarkgfm==2022.10.27 +redis==4.4.4 +gevent==22.10.2 python-dotenv==0.13.0 flask-restx==0.5.1 flask-marshmallow==0.10.1 @@ -25,7 +25,7 @@ boto3==1.13.9 marshmallow==2.20.2 pydantic==1.6.2 WTForms==2.3.1 -python-geoacumen-city==2022.5.15 +python-geoacumen-city==2023.1.15 maxminddb==1.5.4 tenacity==6.2.0 -pybluemonday==0.0.9 +pybluemonday==0.0.10 diff --git a/requirements.txt b/requirements.txt index bfc7307bfb..76a85b598e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,9 +10,11 @@ alembic==1.4.3 # flask-migrate aniso8601==8.0.0 # via flask-restx +async-timeout==4.0.2 + # via redis attrs==20.3.0 # via jsonschema -bcrypt==3.2.2 +bcrypt==4.0.1 # via -r requirements.in boto3==1.13.9 # via -r requirements.in @@ -20,18 +22,17 @@ botocore==1.16.26 # via # boto3 # s3transfer -certifi==2020.11.8 +certifi==2022.12.7 # via requests cffi==1.15.0 # via - # bcrypt # cmarkgfm # pybluemonday charset-normalizer==2.0.12 # via requests click==7.1.2 # via flask -cmarkgfm==0.8.0 +cmarkgfm==2022.10.27 # via -r requirements.in dataset==1.3.1 # via -r requirements.in @@ -60,9 +61,9 @@ flask-sqlalchemy==2.4.3 # via # -r requirements.in # flask-migrate -gevent==21.12.0 +gevent==22.10.2 # via -r requirements.in -greenlet==1.1.2 +greenlet==2.0.1 # via gevent gunicorn==20.1.0 # via -r requirements.in @@ -102,7 +103,7 @@ maxminddb==1.5.4 # python-geoacumen-city passlib==1.7.4 # via -r requirements.in -pybluemonday==0.0.9 +pybluemonday==0.0.10 # via -r requirements.in pycparser==2.20 # via cffi @@ -120,13 +121,13 @@ python-dotenv==0.13.0 # via -r requirements.in python-editor==1.0.4 # via alembic -python-geoacumen-city==2022.5.15 +python-geoacumen-city==2023.1.15 # via -r requirements.in pytz==2020.4 # via flask-restx -redis==3.5.2 +redis==4.4.4 # via -r requirements.in -requests==2.27.1 +requests==2.28.1 # via -r requirements.in s3transfer==0.3.3 # via boto3 diff --git a/tests/api/v1/test_challenges.py b/tests/api/v1/test_challenges.py index 39ecd49a9c..4eb25d8f15 100644 --- a/tests/api/v1/test_challenges.py +++ b/tests/api/v1/test_challenges.py @@ -394,8 +394,9 @@ def test_api_challenges_get_solve_count_banned_user(): assert chal_data["solves"] == 1 # Ban the user - Users.query.get(2).banned = True - app.db.session.commit() + with login_as_user(app, name="admin") as client: + r = client.patch("/api/v1/users/2", json={"banned": True}) + assert Users.query.get(2).banned == True with app.test_client() as client: # Confirm solve count is `0` despite the banned user having solved @@ -823,8 +824,9 @@ def test_api_challenge_get_solve_count_banned_user(): assert chal_data["solves"] == 1 # Ban the user - Users.query.get(2).banned = True - app.db.session.commit() + with login_as_user(app, name="admin") as client: + r = client.patch("/api/v1/users/2", json={"banned": True}) + assert Users.query.get(2).banned == True # Confirm solve count is `0` despite the banned user having solved with app.test_client() as client: diff --git a/tests/api/v1/test_scoreboard.py b/tests/api/v1/test_scoreboard.py index 42daaa6ce9..6347c7e690 100644 --- a/tests/api/v1/test_scoreboard.py +++ b/tests/api/v1/test_scoreboard.py @@ -4,12 +4,15 @@ from flask_caching import make_template_fragment_key from CTFd.cache import clear_standings +from CTFd.models import Users from tests.helpers import ( create_ctfd, destroy_ctfd, + gen_award, gen_challenge, gen_flag, gen_solve, + gen_team, login_as_user, register_user, ) @@ -58,3 +61,95 @@ def test_scoreboard_is_cached(): is None ) destroy_ctfd(app) + + +def test_scoreboard_tie_break_ordering_with_awards(): + """ + Test that scoreboard tie break ordering respects the addition of awards + """ + app = create_ctfd() + with app.app_context(): + # create user1 + register_user(app, name="user1", email="user1@examplectf.com") + # create user2 + register_user(app, name="user2", email="user2@examplectf.com") + + chal = gen_challenge(app.db, value=100) + gen_flag(app.db, challenge_id=chal.id, content="flag") + + chal = gen_challenge(app.db, value=200) + gen_flag(app.db, challenge_id=chal.id, content="flag") + + # create solves for the challenges. (the user_ids are off by 1 because of the admin) + gen_solve(app.db, user_id=2, challenge_id=1) + gen_solve(app.db, user_id=3, challenge_id=2) + + with login_as_user(app, "user1") as client: + r = client.get("/api/v1/scoreboard") + resp = r.get_json() + assert len(resp["data"]) == 2 + assert resp["data"][0]["name"] == "user2" + assert resp["data"][0]["score"] == 200 + assert resp["data"][1]["name"] == "user1" + assert resp["data"][1]["score"] == 100 + + # Give user1 an award for 100 points. + # At this point user2 should still be ahead + gen_award(app.db, user_id=2, value=100) + + with login_as_user(app, "user1") as client: + r = client.get("/api/v1/scoreboard") + resp = r.get_json() + assert len(resp["data"]) == 2 + assert resp["data"][0]["name"] == "user2" + assert resp["data"][0]["score"] == 200 + assert resp["data"][1]["name"] == "user1" + assert resp["data"][1]["score"] == 200 + destroy_ctfd(app) + + +def test_scoreboard_tie_break_ordering_with_awards_under_teams(): + """ + Test that team mode scoreboard tie break ordering respects the addition of awards + """ + app = create_ctfd(user_mode="teams") + with app.app_context(): + gen_team(app.db, name="team1", email="team1@examplectf.com") + gen_team(app.db, name="team2", email="team2@examplectf.com") + + chal = gen_challenge(app.db, value=100) + gen_flag(app.db, challenge_id=chal.id, content="flag") + + chal = gen_challenge(app.db, value=200) + gen_flag(app.db, challenge_id=chal.id, content="flag") + + # create solves for the challenges. (the user_ids are off by 1 because of the admin) + gen_solve(app.db, user_id=2, team_id=1, challenge_id=1) + gen_solve(app.db, user_id=6, team_id=2, challenge_id=2) + + user = Users.query.filter_by(id=2).first() + + with login_as_user(app, user.name) as client: + r = client.get("/api/v1/scoreboard") + resp = r.get_json() + print(resp) + assert len(resp["data"]) == 2 + assert resp["data"][0]["name"] == "team2" + assert resp["data"][0]["score"] == 200 + assert resp["data"][1]["name"] == "team1" + assert resp["data"][1]["score"] == 100 + + # Give a user on the team an award for 100 points. + # At this point team2 should still be ahead + gen_award(app.db, user_id=3, team_id=1, value=100) + + with login_as_user(app, user.name) as client: + r = client.get("/api/v1/scoreboard") + resp = r.get_json() + print(resp) + assert len(resp["data"]) == 2 + assert resp["data"][0]["name"] == "team2" + assert resp["data"][0]["score"] == 200 + assert resp["data"][1]["name"] == "team1" + assert resp["data"][1]["score"] == 200 + destroy_ctfd(app) diff --git a/tests/cache/test_challenges.py b/tests/cache/test_challenges.py new file mode 100644 index 0000000000..73093d5c52 --- /dev/null +++ b/tests/cache/test_challenges.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from CTFd.models import Users +from tests.helpers import ( + create_ctfd, + destroy_ctfd, + login_as_user, + register_user, + simulate_user_activity, +) + + +def test_adding_challenge_clears_cache(): + """ + Test that when we add a challenge, it appears in our challenge list + """ + app = create_ctfd() + with app.app_context(): + register_user(app) + + with login_as_user(app) as client, login_as_user( + app, name="admin", password="password" + ) as admin: + req = client.get("/api/v1/challenges") + data = req.get_json() + assert data["data"] == [] + + challenge_data = { + "name": "name", + "category": "category", + "description": "description", + "value": 100, + "state": "visible", + "type": "standard", + } + + r = admin.post("/api/v1/challenges", json=challenge_data) + assert r.get_json().get("data")["id"] == 1 + + req = client.get("/api/v1/challenges") + data = req.get_json() + assert data["data"] != [] + destroy_ctfd(app) + + +def test_deleting_challenge_clears_cache_solves(): + """ + Test that deleting a challenge clears the cached solves for the challenge + """ + app = create_ctfd() + with app.app_context(): + register_user(app) + user = Users.query.filter_by(id=2).first() + simulate_user_activity(app.db, user) + with login_as_user(app) as client, login_as_user( + app, name="admin", password="password" + ) as admin: + req = client.get("/api/v1/challenges") + data = req.get_json()["data"] + challenge = data[0] + assert challenge["solves"] == 1 + from CTFd.utils.challenges import ( + get_solves_for_challenge_id, + get_solve_counts_for_challenges, + ) + + solves = get_solves_for_challenge_id(1) + solve_counts = get_solve_counts_for_challenges() + solves_req = client.get("/api/v1/challenges/1/solves").get_json()["data"] + assert len(solves_req) == 1 + assert len(solves) == 1 + assert solve_counts[1] == 1 + + r = admin.delete("/api/v1/challenges/1", json="") + assert r.status_code == 200 + + solve_counts = get_solve_counts_for_challenges() + solves = get_solves_for_challenge_id(1) + r = client.get("/api/v1/challenges/1/solves") + assert r.status_code == 404 + assert len(solves) == 0 + assert solve_counts.get(1) is None + destroy_ctfd(app) + + +def test_deleting_solve_clears_cache(): + """ + Test that deleting a solve clears out the solve count cache + """ + app = create_ctfd() + with app.app_context(): + register_user(app) + user = Users.query.filter_by(id=2).first() + simulate_user_activity(app.db, user) + with login_as_user(app) as client, login_as_user( + app, name="admin", password="password" + ) as admin: + req = client.get("/api/v1/challenges") + data = req.get_json()["data"] + challenge = data[0] + assert challenge["solves"] == 1 + from CTFd.utils.challenges import ( + get_solves_for_challenge_id, + get_solve_counts_for_challenges, + ) + + solves = get_solves_for_challenge_id(1) + solve_counts = get_solve_counts_for_challenges() + solves_req = client.get("/api/v1/challenges/1/solves").get_json()["data"] + assert len(solves_req) == 1 + assert len(solves) == 1 + assert solve_counts[1] == 1 + + r = admin.get("/api/v1/submissions/6", json="") + assert r.get_json()["data"]["type"] == "correct" + r = admin.delete("/api/v1/submissions/6", json="") + assert r.status_code == 200 + r = admin.get("/api/v1/submissions/6", json="") + assert r.status_code == 404 + + solve_counts = get_solve_counts_for_challenges() + solves = get_solves_for_challenge_id(1) + solves_req = client.get("/api/v1/challenges/1/solves").get_json()["data"] + assert len(solves_req) == 0 + assert len(solves) == 0 + assert solve_counts.get(1) is None + destroy_ctfd(app) diff --git a/tests/helpers.py b/tests/helpers.py index 2037dfd4a6..665f321d73 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -15,7 +15,7 @@ from werkzeug.datastructures import Headers from CTFd import create_app -from CTFd.cache import cache, clear_standings +from CTFd.cache import cache, clear_challenges, clear_standings from CTFd.config import TestingConfig from CTFd.models import ( Awards, @@ -336,6 +336,7 @@ def gen_challenge( ) db.session.add(chal) db.session.commit() + clear_challenges() return chal @@ -455,6 +456,7 @@ def gen_solve( db.session.add(solve) db.session.commit() clear_standings() + clear_challenges() return solve diff --git a/tests/utils/test_ctftime.py b/tests/utils/test_ctftime.py index 3884c87e2f..81e1abcca8 100644 --- a/tests/utils/test_ctftime.py +++ b/tests/utils/test_ctftime.py @@ -1,11 +1,13 @@ from CTFd.models import Solves from CTFd.utils.dates import ctf_ended, ctf_started +from CTFd.utils.modes import TEAMS_MODE from tests.helpers import ( create_ctfd, ctftime, destroy_ctfd, gen_challenge, gen_flag, + gen_team, login_as_user, register_user, ) @@ -36,6 +38,43 @@ def test_ctftime_prevents_accessing_challenges_before_ctf(): destroy_ctfd(app) +def test_ctftime_redirects_to_teams_page_in_teams_mode_before_ctf(): + """ + Test that the ctftime function redirects users to the team creation page in teams mode before the ctf if the user + has no team yet. + """ + app = create_ctfd(user_mode=TEAMS_MODE) + with app.app_context(): + with ctftime.init(): + register_user(app) + chal = gen_challenge(app.db) + gen_flag(app.db, challenge_id=chal.id, content=u"flag") + + with ctftime.not_started(): + client = login_as_user(app) + r = client.get("/challenges") + assert r.status_code == 302 + + gen_team(app.db, name="test", password="password") + with login_as_user(app) as client: + r = client.get("/teams/join") + assert r.status_code == 200 + with client.session_transaction() as sess: + data = { + "name": "test", + "password": "password", + "nonce": sess.get("nonce"), + } + r = client.post("/teams/join", data=data) + assert r.status_code == 302 + + with ctftime.not_started(): + client = login_as_user(app) + r = client.get("/challenges") + assert r.status_code == 403 + destroy_ctfd(app) + + def test_ctftime_allows_accessing_challenges_during_ctf(): """Test that the ctftime function allows accessing challenges during the ctf""" app = create_ctfd() diff --git a/tests/utils/test_uploaders.py b/tests/utils/test_uploaders.py index 8cf1e54e7f..2a9c05c342 100644 --- a/tests/utils/test_uploaders.py +++ b/tests/utils/test_uploaders.py @@ -1,7 +1,6 @@ import os from io import BytesIO -import boto.s3.connection import boto3 from moto import mock_s3 @@ -11,8 +10,10 @@ @mock_s3 def test_s3_uploader(): - conn = boto3.resource("s3", region_name="fr-evry-u1") - conn.create_bucket(Bucket="bucket", CreateBucketConfiguration={'LocationConstraint': "fr-evry-u1"}) + conn = boto3.resource("s3", region_name="test-region") + conn.create_bucket( + Bucket="bucket", CreateBucketConfiguration={"LocationConstraint": "test-region"} + ) app = create_ctfd() with app.app_context(): @@ -20,7 +21,7 @@ def test_s3_uploader(): app.config["AWS_ACCESS_KEY_ID"] = "AKIAIOSFODNN7EXAMPLE" app.config["AWS_SECRET_ACCESS_KEY"] = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" app.config["AWS_S3_BUCKET"] = "bucket" - app.config["AWS_S3_REGION"] = "country-city-id" + app.config["AWS_S3_REGION"] = "test-region" uploader = S3Uploader() @@ -36,8 +37,10 @@ def test_s3_uploader(): @mock_s3 def test_s3_sync(): - conn = boto3.resource("s3", region_name="us-east-1") - conn.create_bucket(Bucket="bucket") + conn = boto3.resource("s3", region_name="test-region") + conn.create_bucket( + Bucket="bucket", CreateBucketConfiguration={"LocationConstraint": "test-region"} + ) app = create_ctfd() with app.app_context(): @@ -45,6 +48,7 @@ def test_s3_sync(): app.config["AWS_ACCESS_KEY_ID"] = "AKIAIOSFODNN7EXAMPLE" app.config["AWS_SECRET_ACCESS_KEY"] = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" app.config["AWS_S3_BUCKET"] = "bucket" + app.config["AWS_S3_REGION"] = "test-region" uploader = S3Uploader() uploader.sync() diff --git a/yarn.lock b/yarn.lock index 1e12541c5c..f2d8598ebe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2577,7 +2577,7 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -event-source-polyfill@~1.0.7: +event-source-polyfill@1.0.19: version "1.0.19" resolved "https://registry.yarnpkg.com/event-source-polyfill/-/event-source-polyfill-1.0.19.tgz#34d136ccb3b8df37cd889e23799154f176286c84" integrity sha512-QTbtS269q57PCauR4qVwMEtvAoMjHfrVOLABXtKfmT5K68qR8sqZVOMfSPcIAWT+VtzBQj7IESeql6zQOPvwUQ==
-   +  
ID UserEmailEmail Country Admin Verified
-   +  
{{ user.id }}