From 328b523b2417149af4c3b4baf602a04530681c9d Mon Sep 17 00:00:00 2001 From: Kevin Chung Date: Wed, 31 Aug 2022 11:52:20 -0400 Subject: [PATCH 01/41] Fix issue with dynamic_challenges migration loading (#2179) --- CTFd/plugins/dynamic_challenges/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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/" From 4793d95338935686ef005c24ebb6606e1be7403b Mon Sep 17 00:00:00 2001 From: Kevin Chung Date: Wed, 7 Sep 2022 14:40:42 -0400 Subject: [PATCH 02/41] Emit more theme init data using tojson (#2182) * Emit more theme init data using tojson * Add `teamId` and `teamName` into admin `base.html` --- CTFd/themes/admin/templates/base.html | 14 ++++++++------ CTFd/themes/core/templates/base.html | 6 +++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/CTFd/themes/admin/templates/base.html b/CTFd/themes/admin/templates/base.html index ea457509c5..adb435ba4d 100644 --- a/CTFd/themes/admin/templates/base.html +++ b/CTFd/themes/admin/templates/base.html @@ -15,12 +15,14 @@ var init = { 'urlRoot': "{{ request.script_root }}", 'csrfNonce': "{{ Session.nonce }}", - 'userMode': "{{ get_config('user_mode') }}", - 'userId': {{ id if (id is defined) else 0 }}, - 'userName': "{{ User.name }}", - 'userEmail': "{{ User.email }}", - 'start': {{ get_config("start") | tojson }}, - 'end': {{ get_config("end") | tojson }}, + 'userMode': "{{ Configs.user_mode }}", + 'userId': {{ Session.id }}, + 'userName': {{ User.name | tojson }}, + 'userEmail': {{ User.email | tojson }}, + 'teamId': {{ Team.id | tojson }}, + 'teamName': {{ Team.name | tojson }}, + 'start': {{ Configs.start | tojson }}, + 'end': {{ Configs.end | tojson }} } {% block stylesheets %} {% endblock %} 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 }} From f00175a98ffdb995174145e008c07727ea0f59ca Mon Sep 17 00:00:00 2001 From: Smyler Date: Thu, 8 Sep 2022 13:40:22 +0200 Subject: [PATCH 03/41] Enable SAST and optimize CI pieline --- .gitlab-ci.yml | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8691c0e239..8e7c46737d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,6 +1,7 @@ stages: - - linting + - lint - test + - sast - containerize variables: @@ -11,8 +12,11 @@ variables: AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY MYSQL_ROOT_PASSWORD: password +include: +- template: Security/SAST.gitlab-ci.yml + dockerfile: - stage: linting + stage: lint image: hadolint/hadolint:latest-debian script: - mkdir -p reports @@ -26,7 +30,7 @@ dockerfile: - "reports/*" docker-compose: - stage: linting + stage: lint image: python:3.9.13-bullseye script: - python -m pip install docker-compose==1.26.0 @@ -41,6 +45,9 @@ postgres: - redis:latest variables: TESTING_DATABASE_URL: postgres://postgres:password@postgres:5432/ctfd + needs: + - dockerfile + - docker-compose script: - python -m pip install --upgrade pip - python -m pip install -r development.txt @@ -61,6 +68,9 @@ mysql: - redis:latest variables: TESTING_DATABASE_URL: mysql+pymysql://root:password@mysql:3306/ctfd + needs: + - dockerfile + - docker-compose script: - python -m pip install --upgrade pip - python -m pip install -r development.txt @@ -77,6 +87,9 @@ sqlite: image: nikolaik/python-nodejs:python3.9-nodejs18 variables: TESTING_DATABASE_URL: 'sqlite://' + needs: + - dockerfile + - docker-compose script: - python -m pip install --upgrade pip - python -m pip install -r development.txt @@ -88,11 +101,19 @@ sqlite: paths: - coverage.xml +sast: + stage: sast + needs: + - dockerfile + - docker-compose + containerize: stage: containerize image: name: gcr.io/kaniko-project/executor:debug entrypoint: [""] + needs: + - sqlite script: - 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 From 04de6c0b4f8a1856bb298e1ab8af40559d44baca Mon Sep 17 00:00:00 2001 From: Brendan McShane Date: Thu, 15 Sep 2022 16:24:58 -0400 Subject: [PATCH 04/41] Update Docker Image CI/CD (#2183) * Update docker-build.yml to provide ARM builds --- .github/workflows/docker-build.yml | 50 ++++++++++++++++-------------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index ee27652fc3..fdde0eab4f 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -7,38 +7,40 @@ on: jobs: docker: runs-on: ubuntu-latest + permissions: + contents: read + packages: write steps: - - - name: Checkout - uses: actions/checkout@v2 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v1 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - - name: Login to DockerHub - uses: docker/login-action@v1 + - name: Set repo name lowercase + id: repo + uses: ASzc/change-string-case-action@v2 + with: + string: ${{ github.repository }} + - name: Checkout + uses: actions/checkout@v3 + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Login to DockerHub + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Login to GitHub Container Registry - uses: docker/login-action@v1 + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 with: registry: ghcr.io - username: ${{ github.repository_owner }} + username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v2 + - name: Build and push + uses: docker/build-push-action@v3 with: context: . - platforms: linux/amd64 + platforms: linux/amd64,linux/arm64 push: true tags: | - ctfd/ctfd:latest - ghcr.io/ctfd/ctfd:latest - ctfd/ctfd:${{ github.event.release.tag_name }} - ghcr.io/ctfd/ctfd:${{ github.event.release.tag_name }} + ${{ steps.repo.outputs.lowercase }}:latest + ghcr.io/${{ steps.repo.outputs.lowercase }}:latest + ${{ steps.repo.outputs.lowercase }}:${{ github.event.release.tag_name }} + ghcr.io/${{ steps.repo.outputs.lowercase }}:${{ github.event.release.tag_name }} From 02c08f50cc1be2a058a9f0b1eb27dd6a30652945 Mon Sep 17 00:00:00 2001 From: Janos Bonic <86970079+janosdebugs@users.noreply.github.com> Date: Fri, 23 Sep 2022 05:35:43 +0100 Subject: [PATCH 05/41] Redirect users to team creation before event start (#2185) * Redirect users to the team creation page if they access a during_ctf_time_only page before the CTF starts --- CTFd/utils/decorators/__init__.py | 7 ++++-- tests/utils/test_ctftime.py | 39 +++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) 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/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() From eb66034aae849b42d7c15b6659e3cbd33bc18556 Mon Sep 17 00:00:00 2001 From: Smyler Date: Fri, 30 Sep 2022 09:46:47 +0200 Subject: [PATCH 06/41] Add S3 region support (#2188) Co-authored-by: Smyler --- CTFd/config.ini | 4 ++++ CTFd/config.py | 2 ++ CTFd/utils/uploads/uploaders.py | 2 ++ tests/utils/test_uploaders.py | 14 ++++++++++---- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/CTFd/config.ini b/CTFd/config.ini index ecf6e7468f..154aa1c0b1 100644 --- a/CTFd/config.ini +++ b/CTFd/config.ini @@ -129,6 +129,10 @@ AWS_S3_BUCKET = # A URL pointing to a custom S3 implementation. Only used under the s3 uploader. AWS_S3_ENDPOINT_URL = +# AWS_S3_REGION +# The aws region that hosts your bucket. Only used in the s3 uploader. +AWS_S3_REGION = + [logs] # LOG_FOLDER # The location where logs are written. These are the logs for CTFd key submissions, registrations, and logins. The default location is the CTFd/logs folder. diff --git a/CTFd/config.py b/CTFd/config.py index 270f9ba192..c01575f276 100644 --- a/CTFd/config.py +++ b/CTFd/config.py @@ -174,6 +174,8 @@ class ServerConfig(object): AWS_S3_ENDPOINT_URL: str = empty_str_cast(config_ini["uploads"]["AWS_S3_ENDPOINT_URL"]) + AWS_S3_REGION: str = empty_str_cast(config_ini["uploads"]["AWS_S3_REGION"]) + # === OPTIONAL === REVERSE_PROXY: Union[str, bool] = empty_str_cast(config_ini["optional"]["REVERSE_PROXY"], default=False) diff --git a/CTFd/utils/uploads/uploaders.py b/CTFd/utils/uploads/uploaders.py index feb72623fd..1b90c201c4 100644 --- a/CTFd/utils/uploads/uploaders.py +++ b/CTFd/utils/uploads/uploaders.py @@ -85,12 +85,14 @@ def _get_s3_connection(self): access_key = get_app_config("AWS_ACCESS_KEY_ID") secret_key = get_app_config("AWS_SECRET_ACCESS_KEY") endpoint = get_app_config("AWS_S3_ENDPOINT_URL") + region = get_app_config("AWS_S3_REGION") client = boto3.client( "s3", config=Config(signature_version="s3v4"), aws_access_key_id=access_key, aws_secret_access_key=secret_key, endpoint_url=endpoint, + region_name=region, ) return client diff --git a/tests/utils/test_uploaders.py b/tests/utils/test_uploaders.py index 32f3404703..2a9c05c342 100644 --- a/tests/utils/test_uploaders.py +++ b/tests/utils/test_uploaders.py @@ -10,8 +10,10 @@ @mock_s3 def test_s3_uploader(): - 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(): @@ -19,6 +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"] = "test-region" uploader = S3Uploader() @@ -34,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(): @@ -43,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() From 96e6d6612059549273870502dcc59ee64144d73b Mon Sep 17 00:00:00 2001 From: Kevin Chung Date: Fri, 14 Oct 2022 04:26:00 -0400 Subject: [PATCH 07/41] Fix issue where users could login to their team even though they were already on the team (#2198) * Fix issue where users couldn't login to their team even though they were already on the team --- CTFd/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"] From 9e3ebfd301c0052a7afd026ccc5817791b488e61 Mon Sep 17 00:00:00 2001 From: Kevin Chung Date: Sat, 15 Oct 2022 03:41:06 -0400 Subject: [PATCH 08/41] Fix issue where Next selection wouldn't always load in Admin Panel also Closes #2159 (#2199) * Fix issue where Next selection wouldn't always load in Admin Panel * Closes #2159 by pinning `event-source-polyfill` to 1.0.19. Note that we will not be using this polyfill starting with the `core-beta` theme. --- .../admin/assets/js/components/next/NextChallenge.vue | 6 +++++- CTFd/themes/admin/static/js/components.dev.js | 2 +- CTFd/themes/admin/static/js/components.min.js | 2 +- package.json | 2 +- yarn.lock | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) 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 Date: Sun, 16 Oct 2022 12:07:44 -0700 Subject: [PATCH 09/41] Add autofocus to text fields on authentication pages (#2196) * Add autofocus to text fields on authentication pages --- CTFd/forms/auth.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) 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") From a085d0922a6e730c5c479869637a0335fe108bb7 Mon Sep 17 00:00:00 2001 From: Kevin Chung Date: Wed, 2 Nov 2022 16:56:23 -0400 Subject: [PATCH 10/41] Fix issue with scoreboard ordering when an award results in a tie (#2212) * Fix issue with scoreboard ordering when an award results in a tie * Closes #833 --- CTFd/models/__init__.py | 11 +++ CTFd/utils/scores/__init__.py | 36 +++++-- ..._enable_millisecond_precision_in_mysql_.py | 46 +++++++++ tests/api/v1/test_scoreboard.py | 95 +++++++++++++++++++ 4 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 migrations/versions/46a278193a94_enable_millisecond_precision_in_mysql_.py diff --git a/CTFd/models/__init__.py b/CTFd/models/__init__.py index fc39119371..2bfef2d757 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) 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/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/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) From 54ebf824f6ff97705efaca3a26bbe0da5f56069d Mon Sep 17 00:00:00 2001 From: Bradley Jenkins Date: Sat, 5 Nov 2022 13:21:14 +0000 Subject: [PATCH 11/41] Allow `/healthcheck` endpoint to bypass setup (#2215) * fixes #2214: https://petsathome.atlassian.net/browse/PDE-2132: Added "views.healthcheck" Co-authored-by: Kevin Chung --- CTFd/utils/initialization/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/CTFd/utils/initialization/__init__.py b/CTFd/utils/initialization/__init__.py index a7ac22f1e5..903d6168c3 100644 --- a/CTFd/utils/initialization/__init__.py +++ b/CTFd/utils/initialization/__init__.py @@ -198,6 +198,7 @@ def needs_setup(): "views.integrations", "views.themes", "views.files", + "views.healthcheck", ): return else: From 5daa85fce6e8600e9ac0ce41c812c2bdc35f355d Mon Sep 17 00:00:00 2001 From: Kevin Chung Date: Sat, 5 Nov 2022 11:55:40 -0400 Subject: [PATCH 12/41] Fix other issues wih missing autocomplete='off' (#2217) --- .../admin/templates/modals/teams/create.html | 16 +++++++-------- .../admin/templates/modals/teams/edit.html | 16 +++++++-------- .../admin/templates/modals/users/create.html | 20 +++++++++---------- .../admin/templates/modals/users/edit.html | 18 ++++++++--------- CTFd/themes/admin/templates/reset.html | 10 +++++----- CTFd/themes/admin/templates/users/users.html | 4 ++-- 6 files changed, 42 insertions(+), 42 deletions(-) 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/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/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/users/users.html b/CTFd/themes/admin/templates/users/users.html index 28cfbdea83..896cebb478 100644 --- a/CTFd/themes/admin/templates/users/users.html +++ b/CTFd/themes/admin/templates/users/users.html @@ -70,7 +70,7 @@
-   +  
ID @@ -88,7 +88,7 @@
-   +  
{{ user.id }} From 95bfb96a828a2ce414ea6e42e21aa74d8ed840c8 Mon Sep 17 00:00:00 2001 From: Kevin Chung Date: Sat, 5 Nov 2022 18:12:19 -0400 Subject: [PATCH 13/41] Add names_only parameter to get_columns_for_table (#2219) --- CTFd/plugins/migrations.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 From dfa7f878232a4378ff6aa28943a33c5d9b4d1148 Mon Sep 17 00:00:00 2001 From: Kevin Chung Date: Sat, 5 Nov 2022 19:08:12 -0400 Subject: [PATCH 14/41] Adding more protections for 502's during imports (#2220) * Be more defensive on asset loading during imports * On primary databases only import backups when we are actually able to make it to the target migration --- CTFd/utils/exports/__init__.py | 10 ++++++++++ CTFd/utils/initialization/__init__.py | 7 ++++++- CTFd/utils/migrations/__init__.py | 12 ++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) 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 903d6168c3..8e0c376c8c 100644 --- a/CTFd/utils/initialization/__init__.py +++ b/CTFd/utils/initialization/__init__.py @@ -192,6 +192,11 @@ 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", @@ -213,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 From e4a605e23588444af50077425f7de28e852c7a26 Mon Sep 17 00:00:00 2001 From: Kevin Chung Date: Sun, 6 Nov 2022 17:37:15 -0500 Subject: [PATCH 15/41] Change sendmail functions into classes that can be overriden from a plugin (#2221) * Change sendmail functions into classes that can be overriden from a plugin * Deprecate `CTFd.utils.email.mailgun.sendmail` * Deprecate `CTFd.utils.email.smtp.sendmail` --- CTFd/config.ini | 6 ++ CTFd/config.py | 2 + CTFd/utils/config/__init__.py | 3 + CTFd/utils/email/__init__.py | 14 +++-- CTFd/utils/email/mailgun.py | 40 ++----------- CTFd/utils/email/providers/__init__.py | 4 ++ CTFd/utils/email/providers/mailgun.py | 45 +++++++++++++++ CTFd/utils/email/providers/smtp.py | 79 ++++++++++++++++++++++++++ CTFd/utils/email/smtp.py | 78 ++----------------------- 9 files changed, 156 insertions(+), 115 deletions(-) create mode 100644 CTFd/utils/email/providers/__init__.py create mode 100644 CTFd/utils/email/providers/mailgun.py create mode 100644 CTFd/utils/email/providers/smtp.py diff --git a/CTFd/config.ini b/CTFd/config.ini index 154aa1c0b1..39862542b1 100644 --- a/CTFd/config.ini +++ b/CTFd/config.ini @@ -102,6 +102,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..9a31ac131a 100644 --- a/CTFd/config.py +++ b/CTFd/config.py @@ -154,6 +154,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/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/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) From 7e575a2e4763ab18a2a55f5c5235b3ea0a380e4d Mon Sep 17 00:00:00 2001 From: Eduardo Santos <50454358+eduardo010174@users.noreply.github.com> Date: Fri, 18 Nov 2022 17:42:34 +0000 Subject: [PATCH 16/41] Bump CTFd dependencies (#2229) Bump bcrypt, gevent, greenlet, python-geoacumen-city, requests. --- requirements.in | 8 ++++---- requirements.txt | 11 +++++------ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/requirements.in b/requirements.in index d1d4b6a937..9d5014a98d 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 +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==2022.11.15 maxminddb==1.5.4 tenacity==6.2.0 pybluemonday==0.0.9 diff --git a/requirements.txt b/requirements.txt index bfc7307bfb..eb4a72c2ae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ aniso8601==8.0.0 # via flask-restx 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 @@ -24,7 +24,6 @@ certifi==2020.11.8 # via requests cffi==1.15.0 # via - # bcrypt # cmarkgfm # pybluemonday charset-normalizer==2.0.12 @@ -60,9 +59,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 @@ -120,13 +119,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==2022.11.15 # via -r requirements.in pytz==2020.4 # via flask-restx redis==3.5.2 # via -r requirements.in -requests==2.27.1 +requests==2.28.1 # via -r requirements.in s3transfer==0.3.3 # via boto3 From 800fb8260a07f5405bace81d1946c0a091bc02fe Mon Sep 17 00:00:00 2001 From: Kevin Chung Date: Sat, 3 Dec 2022 12:16:11 -0500 Subject: [PATCH 17/41] Clarify Score Visibility and Account Visibility (#2227) * Don't show /scoreboard if we do not have account_visibility * Clarify the behavior of Score Visibility with respect to Account Visibility --- CTFd/scoreboard.py | 6 ++- .../admin/templates/configs/settings.html | 37 +++++++++---------- .../core/templates/components/navbar.html | 2 +- 3 files changed, 24 insertions(+), 21 deletions(-) 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/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/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' %} From d89ac579f226c747b7de1e4657a21da64b58839f Mon Sep 17 00:00:00 2001 From: Kevin Chung Date: Mon, 5 Dec 2022 00:10:30 -0500 Subject: [PATCH 18/41] Cache challenge data for faster loading of /api/v1/challenges (#2232) * Improve response time of `/api/v1/challenges` and `/api/v1/challenges/[challenge_id]/solves` * Rewrite and remove _build_solves_query to make it cacheable * Closes #2209 --- CTFd/admin/__init__.py | 9 +- CTFd/api/v1/challenges.py | 168 +++++++++--------------------- CTFd/api/v1/config.py | 6 +- CTFd/api/v1/submissions.py | 5 +- CTFd/api/v1/teams.py | 11 +- CTFd/api/v1/users.py | 6 +- CTFd/cache/__init__.py | 12 +++ CTFd/utils/challenges/__init__.py | 126 ++++++++++++++++++++++ populate.py | 3 +- tests/api/v1/test_challenges.py | 10 +- tests/cache/test_challenges.py | 128 +++++++++++++++++++++++ tests/helpers.py | 4 +- 12 files changed, 356 insertions(+), 132 deletions(-) create mode 100644 CTFd/utils/challenges/__init__.py create mode 100644 tests/cache/test_challenges.py 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/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/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/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/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/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 From df4d35370795e4604a2db877a4b3b794d2bce4bc Mon Sep 17 00:00:00 2001 From: Smyler Date: Tue, 6 Dec 2022 10:46:25 +0100 Subject: [PATCH 19/41] Add branch name to Docker image name if not the default branch --- .gitlab-ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8e7c46737d..9ed547c321 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -115,10 +115,15 @@ containerize: needs: - sqlite 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 From f0f2b0df56b490bcaae25768a825c067b7cfbe6c Mon Sep 17 00:00:00 2001 From: Smyler Date: Tue, 6 Dec 2022 11:46:10 +0100 Subject: [PATCH 20/41] Update CI to lint Python & JS --- .gitlab-ci.yml | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9ed547c321..ccb956aa73 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,8 +1,8 @@ stages: - - lint - - test + - linting + - testing - sast - - containerize + - containerizing variables: POSTGRES_HOST_AUTH_METHOD: trust @@ -16,7 +16,7 @@ include: - template: Security/SAST.gitlab-ci.yml dockerfile: - stage: lint + stage: linting image: hadolint/hadolint:latest-debian script: - mkdir -p reports @@ -30,14 +30,26 @@ dockerfile: - "reports/*" docker-compose: - stage: lint + stage: linting image: python:3.9.13-bullseye script: - python -m pip install docker-compose==1.26.0 - docker-compose -f docker-compose.yml config +lint: + stage: linting + image: nikolaik/python-nodejs:python3.9-nodejs18 + variables: + TESTING_DATABASE_URL: 'sqlite://' + script: + - python -m pip install --upgrade pip + - python -m pip install -r development.txt + - sudo yarn install --non-interactive + - sudo yarn global add prettier@1.17.0 + - make lint + postgres: - stage: test + stage: testing image: nikolaik/python-nodejs:python3.9-nodejs18 timeout: 24 hours services: @@ -60,7 +72,7 @@ postgres: when: manual mysql: - stage: test + stage: testing image: nikolaik/python-nodejs:python3.9-nodejs18 timeout: 24 hours services: @@ -83,7 +95,7 @@ mysql: when: manual sqlite: - stage: test + stage: testing image: nikolaik/python-nodejs:python3.9-nodejs18 variables: TESTING_DATABASE_URL: 'sqlite://' @@ -108,7 +120,7 @@ sast: - docker-compose containerize: - stage: containerize + stage: containerizing image: name: gcr.io/kaniko-project/executor:debug entrypoint: [""] From 125439e4e95d24c1d159dbc7b8d71ac5cc62a1d1 Mon Sep 17 00:00:00 2001 From: Smyler Date: Tue, 6 Dec 2022 11:51:57 +0100 Subject: [PATCH 21/41] Update CI needs --- .gitlab-ci.yml | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ccb956aa73..0e1d820e47 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,8 +1,8 @@ stages: - - linting - - testing + - lint + - test - sast - - containerizing + - containerize variables: POSTGRES_HOST_AUTH_METHOD: trust @@ -16,7 +16,7 @@ include: - template: Security/SAST.gitlab-ci.yml dockerfile: - stage: linting + stage: lint image: hadolint/hadolint:latest-debian script: - mkdir -p reports @@ -30,14 +30,14 @@ dockerfile: - "reports/*" docker-compose: - stage: linting + stage: lint image: python:3.9.13-bullseye script: - python -m pip install docker-compose==1.26.0 - docker-compose -f docker-compose.yml config lint: - stage: linting + stage: lint image: nikolaik/python-nodejs:python3.9-nodejs18 variables: TESTING_DATABASE_URL: 'sqlite://' @@ -49,7 +49,7 @@ lint: - make lint postgres: - stage: testing + stage: test image: nikolaik/python-nodejs:python3.9-nodejs18 timeout: 24 hours services: @@ -58,8 +58,7 @@ postgres: variables: TESTING_DATABASE_URL: postgres://postgres:password@postgres:5432/ctfd needs: - - dockerfile - - docker-compose + - lint script: - python -m pip install --upgrade pip - python -m pip install -r development.txt @@ -72,7 +71,7 @@ postgres: when: manual mysql: - stage: testing + stage: test image: nikolaik/python-nodejs:python3.9-nodejs18 timeout: 24 hours services: @@ -81,8 +80,7 @@ mysql: variables: TESTING_DATABASE_URL: mysql+pymysql://root:password@mysql:3306/ctfd needs: - - dockerfile - - docker-compose + - lint script: - python -m pip install --upgrade pip - python -m pip install -r development.txt @@ -95,13 +93,12 @@ mysql: when: manual sqlite: - stage: testing + stage: test image: nikolaik/python-nodejs:python3.9-nodejs18 variables: TESTING_DATABASE_URL: 'sqlite://' needs: - - dockerfile - - docker-compose + - lint script: - python -m pip install --upgrade pip - python -m pip install -r development.txt @@ -116,11 +113,12 @@ sqlite: sast: stage: sast needs: + - lint - dockerfile - docker-compose containerize: - stage: containerizing + stage: containerize image: name: gcr.io/kaniko-project/executor:debug entrypoint: [""] From 91c914cddf0b252cf0f6115d1b7e2482634a35e9 Mon Sep 17 00:00:00 2001 From: Smyler Date: Tue, 6 Dec 2022 12:11:23 +0100 Subject: [PATCH 22/41] Remove sudo calls in ci pipeline --- .gitlab-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0e1d820e47..91bf4df7b2 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -44,8 +44,8 @@ lint: script: - python -m pip install --upgrade pip - python -m pip install -r development.txt - - sudo yarn install --non-interactive - - sudo yarn global add prettier@1.17.0 + - yarn install --non-interactive + - yarn global add prettier@1.17.0 - make lint postgres: From 5df127574fb035a13d6cbc703fb31309b0d6c9a3 Mon Sep 17 00:00:00 2001 From: Smyler Date: Tue, 6 Dec 2022 12:32:54 +0100 Subject: [PATCH 23/41] Add coverage reports --- .gitlab-ci.yml | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 91bf4df7b2..61bcefab2a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -66,8 +66,10 @@ postgres: - rm -f /etc/boto.cfg - make test artifacts: - paths: - - coverage.xml + reports: + coverage_report: + coverage_format: cobertura + path: coverage.xml when: manual mysql: @@ -88,8 +90,10 @@ mysql: - rm -f /etc/boto.cfg - make test artifacts: - paths: - - coverage.xml + reports: + coverage_report: + coverage_format: cobertura + path: coverage.xml when: manual sqlite: @@ -107,8 +111,10 @@ sqlite: - rm -f /etc/boto.cfg - make test artifacts: - paths: - - coverage.xml + reports: + coverage_report: + coverage_format: cobertura + path: coverage.xml sast: stage: sast From 338c2716df59274eb82613ffe58c02b4ce8d69fe Mon Sep 17 00:00:00 2001 From: Smyler Date: Tue, 6 Dec 2022 12:58:46 +0100 Subject: [PATCH 24/41] Add pytest reports to CI --- .gitignore | 3 +++ .gitlab-ci.yml | 9 ++++++--- Makefile | 3 ++- 3 files changed, 11 insertions(+), 4 deletions(-) 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 61bcefab2a..e42f663538 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -69,7 +69,8 @@ postgres: reports: coverage_report: coverage_format: cobertura - path: coverage.xml + path: "reports/coverage/*.xml" + junit: "reports/tests/*.xml" when: manual mysql: @@ -93,7 +94,8 @@ mysql: reports: coverage_report: coverage_format: cobertura - path: coverage.xml + path: "reports/coverage/*.xml" + junit: "reports/tests/*.xml" when: manual sqlite: @@ -114,7 +116,8 @@ sqlite: reports: coverage_report: coverage_format: cobertura - path: coverage.xml + path: "reports/coverage/*.xml" + junit: "reports/tests/*.xml" sast: stage: sast 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 \ From ed3f17e1378ee047ef6ca88af2a310ede8b108e7 Mon Sep 17 00:00:00 2001 From: Smyler Date: Tue, 6 Dec 2022 15:49:34 +0100 Subject: [PATCH 25/41] Cache python virtual environment and packages --- .gitlab-ci.yml | 60 +++++++++++++++++++++++++++++++------------------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e42f663538..c52c93ae48 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,5 @@ stages: + - setup - lint - test - sast @@ -11,10 +12,30 @@ 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" include: - template: Security/SAST.gitlab-ci.yml +python venv: + stage: setup + 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" + dockerfile: stage: lint image: hadolint/hadolint:latest-debian @@ -41,9 +62,14 @@ lint: image: nikolaik/python-nodejs:python3.9-nodejs18 variables: TESTING_DATABASE_URL: 'sqlite://' + dependencies: + - python venv + cache: + key: pip-cache + paths: + - "$PIP_CACHE_DIR" script: - - python -m pip install --upgrade pip - - python -m pip install -r development.txt + - source venv/bin/activate - yarn install --non-interactive - yarn global add prettier@1.17.0 - make lint @@ -57,11 +83,10 @@ postgres: - redis:latest variables: TESTING_DATABASE_URL: postgres://postgres:password@postgres:5432/ctfd - needs: - - lint + dependencies: + - python venv script: - - python -m pip install --upgrade pip - - python -m pip install -r development.txt + - source venv/bin/activate - yarn install --non-interactive - rm -f /etc/boto.cfg - make test @@ -82,11 +107,10 @@ mysql: - redis:latest variables: TESTING_DATABASE_URL: mysql+pymysql://root:password@mysql:3306/ctfd - needs: - - lint + dependencies: + - python venv script: - - python -m pip install --upgrade pip - - python -m pip install -r development.txt + - source venv/bin/activate - yarn install --non-interactive - rm -f /etc/boto.cfg - make test @@ -103,11 +127,10 @@ sqlite: image: nikolaik/python-nodejs:python3.9-nodejs18 variables: TESTING_DATABASE_URL: 'sqlite://' - needs: - - lint + dependencies: + - python venv script: - - python -m pip install --upgrade pip - - python -m pip install -r development.txt + - source venv/bin/activate - yarn install --non-interactive - yarn global add prettier@1.17.0 - rm -f /etc/boto.cfg @@ -119,20 +142,11 @@ sqlite: path: "reports/coverage/*.xml" junit: "reports/tests/*.xml" -sast: - stage: sast - needs: - - lint - - dockerfile - - docker-compose - containerize: stage: containerize image: name: gcr.io/kaniko-project/executor:debug entrypoint: [""] - needs: - - sqlite script: - | if [[ "${CI_COMMIT_BRANCH}" != "${CI_DEFAULT_BRANCH}" ]]; From 3c299095cb501a30a05e1e7b96c582f4724cedf2 Mon Sep 17 00:00:00 2001 From: Cryptanalyse <51446657+Cryptanalyse@users.noreply.github.com> Date: Wed, 7 Dec 2022 19:26:50 +0100 Subject: [PATCH 26/41] Fix the order of the solves of the user pages to the chronological ordering (latest first). (#2108) * Fix the order of the solves, fails, awards to be chronological ordering (latest first). --- CTFd/models/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CTFd/models/__init__.py b/CTFd/models/__init__.py index 2bfef2d757..387b873b9e 100644 --- a/CTFd/models/__init__.py +++ b/CTFd/models/__init__.py @@ -433,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) @@ -443,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) @@ -453,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) @@ -679,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") @@ -695,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") @@ -711,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") From 1583869fef55df0b06527f907b17dd1c565e6564 Mon Sep 17 00:00:00 2001 From: Smyler Date: Thu, 8 Dec 2022 17:36:47 +0100 Subject: [PATCH 27/41] Add needs to pipeline --- .gitlab-ci.yml | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c52c93ae48..398b93ddb1 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,5 +1,5 @@ stages: - - setup + - dependencies - lint - test - sast @@ -17,8 +17,8 @@ variables: include: - template: Security/SAST.gitlab-ci.yml -python venv: - stage: setup +python dependencies: + stage: dependencies image: nikolaik/python-nodejs:python3.9-nodejs18 script: - pip install virtualenv @@ -63,7 +63,7 @@ lint: variables: TESTING_DATABASE_URL: 'sqlite://' dependencies: - - python venv + - python dependencies cache: key: pip-cache paths: @@ -84,7 +84,9 @@ postgres: variables: TESTING_DATABASE_URL: postgres://postgres:password@postgres:5432/ctfd dependencies: - - python venv + - python dependencies + needs: + - lint script: - source venv/bin/activate - yarn install --non-interactive @@ -108,7 +110,9 @@ mysql: variables: TESTING_DATABASE_URL: mysql+pymysql://root:password@mysql:3306/ctfd dependencies: - - python venv + - python dependencies + needs: + - lint script: - source venv/bin/activate - yarn install --non-interactive @@ -128,7 +132,9 @@ sqlite: variables: TESTING_DATABASE_URL: 'sqlite://' dependencies: - - python venv + - python dependencies + needs: + - lint script: - source venv/bin/activate - yarn install --non-interactive @@ -147,6 +153,8 @@ containerize: image: name: gcr.io/kaniko-project/executor:debug entrypoint: [""] + needs: + - sqlite script: - | if [[ "${CI_COMMIT_BRANCH}" != "${CI_DEFAULT_BRANCH}" ]]; From 6901fefa8eadfd98adfdbde19886dddc00ecfb7e Mon Sep 17 00:00:00 2001 From: Smyler Date: Thu, 8 Dec 2022 17:41:14 +0100 Subject: [PATCH 28/41] Add needs to pipeline --- .gitlab-ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 398b93ddb1..5c92f9698c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -64,6 +64,8 @@ lint: TESTING_DATABASE_URL: 'sqlite://' dependencies: - python dependencies + needs: + - python dependencies cache: key: pip-cache paths: @@ -86,6 +88,7 @@ postgres: dependencies: - python dependencies needs: + - python dependencies - lint script: - source venv/bin/activate @@ -112,6 +115,7 @@ mysql: dependencies: - python dependencies needs: + - python dependencies - lint script: - source venv/bin/activate @@ -134,6 +138,7 @@ sqlite: dependencies: - python dependencies needs: + - python dependencies - lint script: - source venv/bin/activate From 89dc6cca20e1e4606ba1e9e1048ebfa9882d4be4 Mon Sep 17 00:00:00 2001 From: Smyler Date: Thu, 8 Dec 2022 18:08:17 +0100 Subject: [PATCH 29/41] Cache yarn dependencies --- .gitlab-ci.yml | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5c92f9698c..8159dbf48e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -13,6 +13,7 @@ variables: 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" include: - template: Security/SAST.gitlab-ci.yml @@ -32,9 +33,23 @@ python dependencies: - venv expire_in: 24 hours cache: - key: pip-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: - - "$PIP_CACHE_DIR" + - node_modules + expire_in: 24 hours dockerfile: stage: lint @@ -64,15 +79,16 @@ lint: TESTING_DATABASE_URL: 'sqlite://' dependencies: - python dependencies + - node dependencies needs: - python dependencies + - node dependencies cache: key: pip-cache paths: - "$PIP_CACHE_DIR" script: - source venv/bin/activate - - yarn install --non-interactive - yarn global add prettier@1.17.0 - make lint @@ -87,12 +103,13 @@ postgres: TESTING_DATABASE_URL: postgres://postgres:password@postgres:5432/ctfd dependencies: - python dependencies + - node dependencies needs: - python dependencies + - node dependencies - lint script: - source venv/bin/activate - - yarn install --non-interactive - rm -f /etc/boto.cfg - make test artifacts: @@ -114,12 +131,13 @@ mysql: TESTING_DATABASE_URL: mysql+pymysql://root:password@mysql:3306/ctfd dependencies: - python dependencies + - node dependencies needs: - python dependencies + - node dependencies - lint script: - source venv/bin/activate - - yarn install --non-interactive - rm -f /etc/boto.cfg - make test artifacts: @@ -137,13 +155,13 @@ sqlite: TESTING_DATABASE_URL: 'sqlite://' dependencies: - python dependencies + - node dependencies needs: - python dependencies + - node dependencies - lint script: - source venv/bin/activate - - yarn install --non-interactive - - yarn global add prettier@1.17.0 - rm -f /etc/boto.cfg - make test artifacts: From 57a9b7d285d26037d4d1dbe20d6367361cb52948 Mon Sep 17 00:00:00 2001 From: Smyler Date: Thu, 8 Dec 2022 18:23:00 +0100 Subject: [PATCH 30/41] Split lint jobs into smaller jobs --- .gitlab-ci.yml | 71 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 49 insertions(+), 22 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8159dbf48e..d3bc306d22 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -51,46 +51,63 @@ node dependencies: - node_modules expire_in: 24 hours -dockerfile: +lint dockerfile: stage: lint image: hadolint/hadolint:latest-debian 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: +lint docker-compose: stage: lint image: python:3.9.13-bullseye script: - python -m pip install docker-compose==1.26.0 - docker-compose -f docker-compose.yml config -lint: +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 - variables: - TESTING_DATABASE_URL: 'sqlite://' dependencies: - python dependencies - - node dependencies needs: - python dependencies - - node dependencies - cache: - key: pip-cache - paths: - - "$PIP_CACHE_DIR" 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 - - make lint + - prettier --check 'CTFd/themes/**/assets/**/*' + - prettier --check '**/*.md' postgres: stage: test @@ -107,7 +124,10 @@ postgres: needs: - python dependencies - node dependencies - - lint + - flake8 + - black + - yarn lint + - prettier script: - source venv/bin/activate - rm -f /etc/boto.cfg @@ -135,7 +155,10 @@ mysql: needs: - python dependencies - node dependencies - - lint + - flake8 + - black + - yarn lint + - prettier script: - source venv/bin/activate - rm -f /etc/boto.cfg @@ -159,7 +182,10 @@ sqlite: needs: - python dependencies - node dependencies - - lint + - flake8 + - black + - yarn lint + - prettier script: - source venv/bin/activate - rm -f /etc/boto.cfg @@ -178,6 +204,7 @@ containerize: entrypoint: [""] needs: - sqlite + - lint dockerfile script: - | if [[ "${CI_COMMIT_BRANCH}" != "${CI_DEFAULT_BRANCH}" ]]; From 81d439b79d750790dfd422a70a52e905bcd1860c Mon Sep 17 00:00:00 2001 From: Smyler Date: Thu, 8 Dec 2022 18:33:08 +0100 Subject: [PATCH 31/41] Use empty needs for docker linting --- .gitlab-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d3bc306d22..4d502b51dd 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -54,6 +54,7 @@ node dependencies: 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 @@ -61,6 +62,7 @@ lint dockerfile: 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 From 8c6063101c8c558d4beeb395d3408fa2e3a8f7a2 Mon Sep 17 00:00:00 2001 From: Smyler Date: Thu, 8 Dec 2022 18:39:58 +0100 Subject: [PATCH 32/41] Make SAST tests work on dependencies --- .gitlab-ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4d502b51dd..ac79008e17 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -199,6 +199,14 @@ sqlite: path: "reports/coverage/*.xml" junit: "reports/tests/*.xml" +sast: + dependencies: + - python dependencies + - node dependencies + needs: + - python dependencies + - node dependencies + containerize: stage: containerize image: From 8b13066341d1324473aa3e4f5cd6b00c7a9d407c Mon Sep 17 00:00:00 2001 From: Smyler Date: Thu, 8 Dec 2022 19:26:14 +0100 Subject: [PATCH 33/41] Split test job into smaller jobs --- .gitlab-ci.yml | 83 ++++++++++++++++++++++++-------------------------- 1 file changed, 40 insertions(+), 43 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ac79008e17..5c17ffc71c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -111,93 +111,88 @@ prettier: - prettier --check 'CTFd/themes/**/assets/**/*' - prettier --check '**/*.md' -postgres: +.pytest: stage: test image: nikolaik/python-nodejs:python3.9-nodejs18 - timeout: 24 hours - services: - - postgres:latest - - redis:latest - variables: - TESTING_DATABASE_URL: postgres://postgres:password@postgres:5432/ctfd dependencies: - python dependencies - - node dependencies needs: - python dependencies - node dependencies - flake8 - black - - yarn lint - - prettier script: - source venv/bin/activate - rm -f /etc/boto.cfg - - make test + - | + 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: + - redis:latest + - postgres:latest + variables: + TESTING_DATABASE_URL: postgres://postgres:password@postgres:5432/ctfd + 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 + DB_DRIVER: "mysql" + when: manual + +sqlite: + 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 - - node dependencies needs: - python dependencies - - node dependencies - flake8 - black - - yarn lint - - prettier script: - source venv/bin/activate - - rm -f /etc/boto.cfg - - make test - artifacts: - reports: - coverage_report: - coverage_format: cobertura - path: "reports/coverage/*.xml" - junit: "reports/tests/*.xml" - when: manual + - bandit -r CTFd -x CTFd/uploads --skip B105,B322 -sqlite: - stage: test +yarn verify: image: nikolaik/python-nodejs:python3.9-nodejs18 - variables: - TESTING_DATABASE_URL: 'sqlite://' dependencies: - - python dependencies - node dependencies needs: - - python dependencies - node dependencies - - flake8 - - black - yarn lint - prettier script: - - source venv/bin/activate - - rm -f /etc/boto.cfg - - make test - artifacts: - reports: - coverage_report: - coverage_format: cobertura - path: "reports/coverage/*.xml" - junit: "reports/tests/*.xml" + - yarn verify sast: dependencies: @@ -215,6 +210,8 @@ containerize: needs: - sqlite - lint dockerfile + - bandit + - yarn verify script: - | if [[ "${CI_COMMIT_BRANCH}" != "${CI_DEFAULT_BRANCH}" ]]; From 167bac79bbfd66c12092e8f83bb876eecde8430d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Dec 2022 23:47:05 -0700 Subject: [PATCH 34/41] Bump certifi from 2020.11.8 to 2022.12.7 (#2234) Bumps [certifi](https://github.com/certifi/python-certifi) from 2020.11.8 to 2022.12.7. - [Release notes](https://github.com/certifi/python-certifi/releases) - [Commits](https://github.com/certifi/python-certifi/compare/2020.11.08...2022.12.07) --- updated-dependencies: - dependency-name: certifi dependency-type: indirect ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index eb4a72c2ae..0b07ec89bb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ botocore==1.16.26 # via # boto3 # s3transfer -certifi==2020.11.8 +certifi==2022.12.7 # via requests cffi==1.15.0 # via From 6f8f7d928c8fb2657b6dc139c8568ea97ae2d7d7 Mon Sep 17 00:00:00 2001 From: Thomas Bork Date: Thu, 22 Dec 2022 22:10:27 -0700 Subject: [PATCH 35/41] Add individual DATABASE_* options, as an alternative to DATABASE_URL (#2237) Co-authored-by: Kevin Chung --- CTFd/config.ini | 30 +++++++++++++++++++++++++++++- CTFd/config.py | 19 +++++++++++++++++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/CTFd/config.ini b/CTFd/config.ini index 39862542b1..1c22bd3d57 100644 --- a/CTFd/config.ini +++ b/CTFd/config.ini @@ -26,10 +26,38 @@ 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 # diff --git a/CTFd/config.py b/CTFd/config.py index 9a31ac131a..584ec71a0c 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,8 +85,21 @@ 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"]) From 49bc81e5176ab12465257385555218632b0d31c2 Mon Sep 17 00:00:00 2001 From: Thomas Bork Date: Wed, 18 Jan 2023 21:05:01 -0700 Subject: [PATCH 36/41] Add individual REDIS_* options, as an alternative to REDIS_URL (#2245) * Add individual REDIS_* options, as an alternative to REDIS_URL * Clarify supported protocols for REDIS_PROTOCOL setting --- CTFd/config.ini | 33 ++++++++++++++++++++++++++++++++- CTFd/config.py | 19 ++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/CTFd/config.ini b/CTFd/config.ini index 1c22bd3d57..6db6db99ea 100644 --- a/CTFd/config.ini +++ b/CTFd/config.ini @@ -59,12 +59,43 @@ DATABASE_PORT = 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. diff --git a/CTFd/config.py b/CTFd/config.py index 584ec71a0c..52d3725185 100644 --- a/CTFd/config.py +++ b/CTFd/config.py @@ -103,8 +103,25 @@ class ServerConfig(object): 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: From 57e2154e0495a0a3eb1f346bc1c8325ba21d834a Mon Sep 17 00:00:00 2001 From: Bin We Date: Sat, 21 Jan 2023 16:33:26 +0800 Subject: [PATCH 37/41] Fix display error on mobile devices (#2244) * Add table-responsive class to more tables in the Admin Panel to improve mobile view --- .../admin/templates/challenges/challenges.html | 18 +++++++++--------- .../templates/modals/teams/addresses.html | 2 +- .../templates/modals/users/addresses.html | 2 +- CTFd/themes/admin/templates/pages.html | 2 +- CTFd/themes/admin/templates/scoreboard.html | 2 +- CTFd/themes/admin/templates/submissions.html | 2 +- CTFd/themes/admin/templates/teams/team.html | 10 +++++----- CTFd/themes/admin/templates/teams/teams.html | 2 +- CTFd/themes/admin/templates/users/user.html | 8 ++++---- CTFd/themes/admin/templates/users/users.html | 4 ++-- 10 files changed, 26 insertions(+), 26 deletions(-) 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/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/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/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/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 896cebb478..83a8d29b3f 100644 --- a/CTFd/themes/admin/templates/users/users.html +++ b/CTFd/themes/admin/templates/users/users.html @@ -64,7 +64,7 @@
-
+
@@ -75,7 +75,7 @@
- + From 89289ad641a7fac3b01185ac892820e6676da675 Mon Sep 17 00:00:00 2001 From: Kevin Chung Date: Mon, 23 Jan 2023 10:34:49 -0500 Subject: [PATCH 38/41] Mark 3.5.1 (#2246) # 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 - 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 --- CHANGELOG.md | 76 ++++++++++++++++++++++++++++++++++++++++++++++++ CTFd/__init__.py | 2 +- package.json | 2 +- requirements.in | 4 +-- requirements.txt | 4 +-- 5 files changed, 82 insertions(+), 6 deletions(-) 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/package.json b/package.json index 96bc4fff7c..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": { diff --git a/requirements.in b/requirements.in index 9d5014a98d..db8f653a20 100644 --- a/requirements.in +++ b/requirements.in @@ -14,7 +14,7 @@ requests==2.28.1 PyMySQL==0.9.3 gunicorn==20.1.0 dataset==1.3.1 -cmarkgfm==0.8.0 +cmarkgfm==2022.10.27 redis==3.5.2 gevent==22.10.2 python-dotenv==0.13.0 @@ -25,7 +25,7 @@ boto3==1.13.9 marshmallow==2.20.2 pydantic==1.6.2 WTForms==2.3.1 -python-geoacumen-city==2022.11.15 +python-geoacumen-city==2023.1.15 maxminddb==1.5.4 tenacity==6.2.0 pybluemonday==0.0.9 diff --git a/requirements.txt b/requirements.txt index 0b07ec89bb..f9db78da9f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,7 +30,7 @@ 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 @@ -119,7 +119,7 @@ python-dotenv==0.13.0 # via -r requirements.in python-editor==1.0.4 # via alembic -python-geoacumen-city==2022.11.15 +python-geoacumen-city==2023.1.15 # via -r requirements.in pytz==2020.4 # via flask-restx From 140a69ca3fb04190efa4e8ef44fa6583c521edca Mon Sep 17 00:00:00 2001 From: Kevin Chung Date: Thu, 13 Apr 2023 07:36:08 +0200 Subject: [PATCH 39/41] Bump pybluemonday version (#2285) * Bump pybluemonday version * Remove codecov from development.txt (cherry picked from commit c405fbb9b153a4da89eca7ed2172365727692791) --- development.txt | 1 - requirements.in | 2 +- requirements.txt | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) 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/requirements.in b/requirements.in index db8f653a20..a07b639688 100644 --- a/requirements.in +++ b/requirements.in @@ -28,4 +28,4 @@ WTForms==2.3.1 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 f9db78da9f..b42f98966e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -101,7 +101,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 From e9c36889aa85587866a51f37bd102c54bc4b02ff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Apr 2023 01:55:16 -0400 Subject: [PATCH 40/41] Bump redis from 3.5.2 to 4.4.4 (#2275) Bumps [redis](https://github.com/redis/redis-py) from 3.5.2 to 4.4.4. - [Release notes](https://github.com/redis/redis-py/releases) - [Changelog](https://github.com/redis/redis-py/blob/master/CHANGES) - [Commits](https://github.com/redis/redis-py/compare/3.5.2...v4.4.4) --- updated-dependencies: - dependency-name: redis dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Kevin Chung (cherry picked from commit 440aaddfb12e1d8957b36b2af7f3072a15c4a69f) --- requirements.in | 2 +- requirements.txt | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/requirements.in b/requirements.in index a07b639688..88e6ca4c4e 100644 --- a/requirements.in +++ b/requirements.in @@ -15,7 +15,7 @@ PyMySQL==0.9.3 gunicorn==20.1.0 dataset==1.3.1 cmarkgfm==2022.10.27 -redis==3.5.2 +redis==4.4.4 gevent==22.10.2 python-dotenv==0.13.0 flask-restx==0.5.1 diff --git a/requirements.txt b/requirements.txt index b42f98966e..76a85b598e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,8 @@ 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==4.0.1 @@ -123,7 +125,7 @@ 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.28.1 # via -r requirements.in From cc1c89a7008f936e178f91c0ceaa74f16de9fb6c Mon Sep 17 00:00:00 2001 From: Smyler Date: Fri, 21 Apr 2023 21:34:27 +0200 Subject: [PATCH 41/41] Exclude populate.py from SAST --- .gitlab-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5c17ffc71c..298e05a3da 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -14,6 +14,7 @@ variables: 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" include: - template: Security/SAST.gitlab-ci.yml
ID UserEmailEmail Country Admin Verified