From 2e06f92c6473efcd9d7e96e925f24b347a511771 Mon Sep 17 00:00:00 2001 From: gkr Date: Thu, 25 Dec 2025 09:39:21 +0800 Subject: [PATCH] init CTFd source --- .codecov.yml | 9 + .dockerignore | 19 + .eslintrc.js | 18 + .flaskenv | 2 + .gitattributes | 1 + .github/FUNDING.yml | 3 + .github/ISSUE_TEMPLATE.md | 19 + .github/workflows/docker-build.yml | 46 + .github/workflows/lint.yml | 47 + .github/workflows/mariadb.yml | 59 + .github/workflows/mirror-core-theme.yml | 30 + .github/workflows/mysql.yml | 59 + .github/workflows/mysql8.yml | 59 + .github/workflows/postgres.yml | 67 + .github/workflows/sqlite.yml | 49 + .github/workflows/verify-themes.yml | 38 + .gitignore | 82 + .isort.cfg | 7 + .prettierignore | 17 + CHANGELOG.md | 2390 ++++++++ CONTRIBUTING.md | 21 + CTFd/__init__.py | 354 ++ CTFd/admin/__init__.py | 279 + CTFd/admin/challenges.py | 120 + CTFd/admin/notifications.py | 12 + CTFd/admin/pages.py | 49 + CTFd/admin/scoreboard.py | 16 + CTFd/admin/statistics.py | 217 + CTFd/admin/submissions.py | 65 + CTFd/admin/teams.py | 86 + CTFd/admin/users.py | 109 + CTFd/api/__init__.py | 75 + CTFd/api/v1/__init__.py | 0 CTFd/api/v1/awards.py | 177 + CTFd/api/v1/brackets.py | 89 + CTFd/api/v1/challenges.py | 1275 +++++ CTFd/api/v1/comments.py | 159 + CTFd/api/v1/config.py | 286 + CTFd/api/v1/exports.py | 46 + CTFd/api/v1/files.py | 181 + CTFd/api/v1/flags.py | 206 + CTFd/api/v1/helpers/__init__.py | 0 CTFd/api/v1/helpers/models.py | 12 + CTFd/api/v1/helpers/request.py | 98 + CTFd/api/v1/helpers/schemas.py | 35 + CTFd/api/v1/hints.py | 237 + CTFd/api/v1/notifications.py | 189 + CTFd/api/v1/pages.py | 177 + CTFd/api/v1/schemas/__init__.py | 105 + CTFd/api/v1/scoreboard.py | 99 + CTFd/api/v1/shares.py | 25 + CTFd/api/v1/solutions.py | 235 + CTFd/api/v1/statistics/__init__.py | 12 + CTFd/api/v1/statistics/challenges.py | 137 + CTFd/api/v1/statistics/scores.py | 43 + CTFd/api/v1/statistics/submissions.py | 23 + CTFd/api/v1/statistics/teams.py | 14 + CTFd/api/v1/statistics/users.py | 30 + CTFd/api/v1/submissions.py | 260 + CTFd/api/v1/tags.py | 166 + CTFd/api/v1/teams.py | 669 +++ CTFd/api/v1/tokens.py | 155 + CTFd/api/v1/topics.py | 181 + CTFd/api/v1/unlocks.py | 205 + CTFd/api/v1/users.py | 537 ++ CTFd/auth.py | 682 +++ CTFd/cache/__init__.py | 253 + CTFd/challenges.py | 49 + CTFd/cli/__init__.py | 92 + CTFd/config.ini | 361 ++ CTFd/config.py | 331 ++ CTFd/constants/__init__.py | 63 + CTFd/constants/assets.py | 79 + CTFd/constants/config.py | 74 + CTFd/constants/email.py | 31 + CTFd/constants/languages.py | 61 + CTFd/constants/options.py | 43 + CTFd/constants/plugins.py | 54 + CTFd/constants/sessions.py | 18 + CTFd/constants/setup.py | 21 + CTFd/constants/static.py | 14 + CTFd/constants/teams.py | 46 + CTFd/constants/themes.py | 2 + CTFd/constants/users.py | 48 + CTFd/errors.py | 21 + CTFd/events/__init__.py | 26 + CTFd/exceptions/__init__.py | 14 + CTFd/exceptions/challenges.py | 10 + CTFd/exceptions/email.py | 6 + CTFd/fonts/OFL.txt | 88 + CTFd/fonts/OpenSans-Bold.ttf | Bin 0 -> 147264 bytes CTFd/forms/__init__.py | 51 + CTFd/forms/auth.py | 88 + CTFd/forms/awards.py | 30 + CTFd/forms/challenges.py | 30 + CTFd/forms/config.py | 344 ++ CTFd/forms/email.py | 10 + CTFd/forms/fields.py | 17 + CTFd/forms/language.py | 19 + CTFd/forms/notifications.py | 26 + CTFd/forms/pages.py | 48 + CTFd/forms/self.py | 61 + CTFd/forms/setup.py | 157 + CTFd/forms/submissions.py | 22 + CTFd/forms/teams.py | 289 + CTFd/forms/users.py | 235 + CTFd/logs/.gitkeep | 0 CTFd/models/__init__.py | 1195 ++++ CTFd/plugins/__init__.py | 209 + CTFd/plugins/challenges/__init__.py | 342 ++ CTFd/plugins/challenges/assets/create.html | 1 + CTFd/plugins/challenges/assets/create.js | 4 + CTFd/plugins/challenges/assets/update.html | 1 + CTFd/plugins/challenges/assets/update.js | 0 CTFd/plugins/challenges/assets/view.html | 1 + CTFd/plugins/challenges/assets/view.js | 37 + CTFd/plugins/challenges/decay.py | 75 + CTFd/plugins/challenges/logic.py | 130 + CTFd/plugins/ctfd-whale/.gitignore | 3 + CTFd/plugins/ctfd-whale/CHANGELOG.md | 91 + CTFd/plugins/ctfd-whale/LICENSE | 21 + CTFd/plugins/ctfd-whale/README.md | 40 + CTFd/plugins/ctfd-whale/README.zh-cn.md | 39 + CTFd/plugins/ctfd-whale/__init__.py | 124 + CTFd/plugins/ctfd-whale/api.py | 138 + CTFd/plugins/ctfd-whale/assets/config.js | 27 + CTFd/plugins/ctfd-whale/assets/containers.js | 120 + CTFd/plugins/ctfd-whale/assets/create.html | 100 + CTFd/plugins/ctfd-whale/assets/create.js | 30 + CTFd/plugins/ctfd-whale/assets/update.html | 94 + CTFd/plugins/ctfd-whale/assets/update.js | 52 + CTFd/plugins/ctfd-whale/assets/view.html | 36 + CTFd/plugins/ctfd-whale/assets/view.js | 239 + CTFd/plugins/ctfd-whale/challenge_type.py | 108 + CTFd/plugins/ctfd-whale/decorators.py | 53 + .../ctfd-whale/docker-compose.example.yml | 105 + CTFd/plugins/ctfd-whale/docs/advanced.md | 156 + .../plugins/ctfd-whale/docs/advanced.zh-cn.md | 268 + CTFd/plugins/ctfd-whale/docs/imgs/arch.png | Bin 0 -> 80104 bytes .../ctfd-whale/docs/imgs/whale-config1.png | Bin 0 -> 111881 bytes .../ctfd-whale/docs/imgs/whale-config2.png | Bin 0 -> 47065 bytes .../ctfd-whale/docs/imgs/whale-config3.png | Bin 0 -> 68153 bytes CTFd/plugins/ctfd-whale/docs/install.md | 304 + CTFd/plugins/ctfd-whale/docs/install.zh-cn.md | 313 + CTFd/plugins/ctfd-whale/models.py | 105 + CTFd/plugins/ctfd-whale/requirements.txt | 4 + .../templates/config/base.router.config.html | 24 + .../templates/config/challenges.config.html | 25 + .../templates/config/docker.config.html | 122 + .../templates/config/frp.router.config.html | 50 + .../templates/config/limits.config.html | 26 + .../templates/config/trp.router.config.html | 17 + .../templates/containers/card.containers.html | 57 + .../templates/containers/list.containers.html | 78 + .../ctfd-whale/templates/whale_base.html | 25 + .../ctfd-whale/templates/whale_config.html | 38 + .../templates/whale_containers.html | 69 + CTFd/plugins/ctfd-whale/utils/__init__.py | 0 CTFd/plugins/ctfd-whale/utils/cache.py | 150 + CTFd/plugins/ctfd-whale/utils/checks.py | 50 + CTFd/plugins/ctfd-whale/utils/control.py | 61 + CTFd/plugins/ctfd-whale/utils/db.py | 104 + CTFd/plugins/ctfd-whale/utils/docker.py | 202 + CTFd/plugins/ctfd-whale/utils/exceptions.py | 8 + .../ctfd-whale/utils/routers/__init__.py | 34 + CTFd/plugins/ctfd-whale/utils/routers/base.py | 25 + CTFd/plugins/ctfd-whale/utils/routers/frp.py | 132 + CTFd/plugins/ctfd-whale/utils/routers/trp.py | 69 + CTFd/plugins/ctfd-whale/utils/setup.py | 60 + CTFd/plugins/dynamic_challenges/.gitignore | 2 + CTFd/plugins/dynamic_challenges/README.md | 54 + CTFd/plugins/dynamic_challenges/__init__.py | 154 + .../dynamic_challenges/assets/create.html | 63 + .../dynamic_challenges/assets/create.js | 4 + .../dynamic_challenges/assets/update.html | 71 + .../dynamic_challenges/assets/update.js | 0 .../dynamic_challenges/assets/view.html | 1 + .../plugins/dynamic_challenges/assets/view.js | 37 + CTFd/plugins/dynamic_challenges/decay.py | 75 + CTFd/plugins/dynamic_challenges/function.png | Bin 0 -> 2460 bytes ...dd_dynamic_prefix_to_dynamic_challenge_.py | 142 + ...8807ea_add_cascading_delete_to_dynamic_.py | 58 + ...1_add_func_column_to_dynamic_challenges.py | 44 + CTFd/plugins/flags/__init__.py | 83 + CTFd/plugins/flags/assets/regex/create.html | 37 + CTFd/plugins/flags/assets/regex/edit.html | 19 + CTFd/plugins/flags/assets/static/create.html | 14 + CTFd/plugins/flags/assets/static/edit.html | 19 + CTFd/plugins/flags/tests/__init__.py | 0 CTFd/plugins/flags/tests/test_flags.py | 34 + CTFd/plugins/migrations.py | 109 + CTFd/schemas/__init__.py | 0 CTFd/schemas/awards.py | 48 + CTFd/schemas/brackets.py | 8 + CTFd/schemas/challenges.py | 74 + CTFd/schemas/comments.py | 14 + CTFd/schemas/config.py | 43 + CTFd/schemas/fields.py | 38 + CTFd/schemas/files.py | 18 + CTFd/schemas/flags.py | 18 + CTFd/schemas/hints.py | 43 + CTFd/schemas/notifications.py | 23 + CTFd/schemas/pages.py | 77 + CTFd/schemas/ratings.py | 38 + CTFd/schemas/solutions.py | 40 + CTFd/schemas/submissions.py | 52 + CTFd/schemas/tags.py | 20 + CTFd/schemas/teams.py | 416 ++ CTFd/schemas/tokens.py | 31 + CTFd/schemas/topics.py | 38 + CTFd/schemas/unlocks.py | 23 + CTFd/schemas/users.py | 416 ++ CTFd/scoreboard.py | 29 + CTFd/share.py | 36 + CTFd/teams.py | 403 ++ CTFd/themes/admin/assets/css/admin.scss | 128 + .../admin/assets/css/challenge-board.scss | 68 + CTFd/themes/admin/assets/css/codemirror.scss | 6 + CTFd/themes/admin/assets/css/fonts.scss | 28 + .../assets/css/includes/award-icons.scss | 39 + .../admin/assets/css/includes/easymde.scss | 381 ++ .../admin/assets/css/includes/flag-icons.scss | 511 ++ .../admin/assets/css/includes/jumbotron.css | 4 + .../admin/assets/css/includes/min-height.scss | 15 + .../admin/assets/css/includes/opacity.scss | 15 + .../assets/css/includes/sticky-footer.css | 30 + CTFd/themes/admin/assets/css/main.scss | 160 + .../admin/assets/js/challenges/challenge.js | 200 + CTFd/themes/admin/assets/js/challenges/new.js | 81 + .../themes/admin/assets/js/challenges/tags.js | 43 + CTFd/themes/admin/assets/js/compat/CTFd.js | 77 + CTFd/themes/admin/assets/js/compat/api.js | 3619 ++++++++++++ CTFd/themes/admin/assets/js/compat/config.js | 5 + CTFd/themes/admin/assets/js/compat/events.js | 122 + CTFd/themes/admin/assets/js/compat/ezq.js | 221 + CTFd/themes/admin/assets/js/compat/fetch.js | 25 + CTFd/themes/admin/assets/js/compat/format.js | 10 + CTFd/themes/admin/assets/js/compat/graphs.js | 339 ++ CTFd/themes/admin/assets/js/compat/helpers.js | 129 + CTFd/themes/admin/assets/js/compat/json.js | 39 + CTFd/themes/admin/assets/js/compat/math.js | 9 + CTFd/themes/admin/assets/js/compat/patch.js | 388 ++ CTFd/themes/admin/assets/js/compat/styles.js | 20 + CTFd/themes/admin/assets/js/compat/times.js | 14 + CTFd/themes/admin/assets/js/compat/ui.js | 20 + CTFd/themes/admin/assets/js/compat/wc.js | 171 + .../js/components/comments/CommentBox.vue | 225 + .../components/configs/brackets/Bracket.vue | 150 + .../configs/brackets/BracketList.vue | 72 + .../js/components/configs/fields/Field.vue | 200 + .../components/configs/fields/FieldList.vue | 82 + .../components/files/ChallengeFilesList.vue | 131 + .../js/components/files/MediaLibrary.vue | 368 ++ .../js/components/flags/FlagCreationForm.vue | 143 + .../js/components/flags/FlagEditForm.vue | 124 + .../assets/js/components/flags/FlagList.vue | 150 + .../js/components/hints/HintCreationForm.vue | 175 + .../js/components/hints/HintEditForm.vue | 238 + .../assets/js/components/hints/HintsList.vue | 159 + .../js/components/next/NextChallenge.vue | 132 + .../components/notifications/Notification.vue | 63 + .../js/components/ratings/RatingsViewer.vue | 207 + .../components/requirements/Requirements.vue | 235 + .../js/components/solution/SolutionEditor.vue | 204 + .../assets/js/components/tags/TagsList.vue | 92 + .../js/components/teams/UserAddForm.vue | 227 + .../js/components/topics/TopicsList.vue | 188 + .../themes/admin/assets/js/pages/challenge.js | 461 ++ .../admin/assets/js/pages/challenges.js | 112 + CTFd/themes/admin/assets/js/pages/configs.js | 563 ++ CTFd/themes/admin/assets/js/pages/editor.js | 111 + CTFd/themes/admin/assets/js/pages/events.js | 7 + CTFd/themes/admin/assets/js/pages/main.js | 29 + .../admin/assets/js/pages/notifications.js | 66 + CTFd/themes/admin/assets/js/pages/pages.js | 33 + CTFd/themes/admin/assets/js/pages/reset.js | 18 + .../admin/assets/js/pages/scoreboard.js | 105 + .../admin/assets/js/pages/statistics.js | 680 +++ CTFd/themes/admin/assets/js/pages/style.js | 8 + .../admin/assets/js/pages/submissions.js | 137 + CTFd/themes/admin/assets/js/pages/team.js | 613 ++ CTFd/themes/admin/assets/js/pages/teams.js | 81 + CTFd/themes/admin/assets/js/pages/user.js | 529 ++ CTFd/themes/admin/assets/js/pages/users.js | 89 + CTFd/themes/admin/assets/js/styles.js | 192 + .../assets/js/templates/admin-flags-table.njk | 22 + CTFd/themes/admin/assets/js/timezones.js | 603 ++ CTFd/themes/admin/package.json | 40 + .../static/assets/CommentBox-CBClD8yz.js | 1 + .../static/assets/CommentBox-CZ8OEr5k.css | 1 + .../static/assets/admin-css-Bj5aeLSk.css | 1 + .../static/assets/challenge-DY7T5JQl.css | 1 + .../assets/challenge-board-css-CpAEjUPb.css | 1 + .../static/assets/codemirror-css-DRHJUI8W.css | 1 + .../admin/static/assets/echarts-l0sNRNKZ.js | 1 + .../static/assets/echarts.common-mykzcWB7.js | 65 + .../static/assets/fonts-css-B0NUqHPX.css | 5 + .../admin/static/assets/graphs-jArcrQQ8.js | 1 + .../admin/static/assets/htmlmixed-vBdNL_dI.js | 24 + .../admin/static/assets/main-css-CoszOyzj.css | 6 + .../static/assets/pages/challenge-BpP8h_jM.js | 5 + .../assets/pages/challenges-D4GbEGKt.js | 29 + .../static/assets/pages/configs-DdgMjjoH.js | 12 + .../static/assets/pages/editor-Cjao4UkU.js | 3 + .../static/assets/pages/main-CcyKUC_q.js | 263 + .../assets/pages/notifications-wuA3N3gF.js | 1 + .../static/assets/pages/pages-CHd53Cqu.js | 1 + .../static/assets/pages/reset-B_I5EjEB.js | 1 + .../assets/pages/scoreboard-DHe7f0Yh.js | 12 + .../assets/pages/statistics-axPwqrbc.js | 4 + .../assets/pages/submissions-DIux1aJH.js | 1 + .../static/assets/pages/team-CiYqyHrZ.js | 6 + .../static/assets/pages/teams-4c2lZGg6.js | 20 + .../static/assets/pages/user-DtkurpXM.js | 1 + .../static/assets/pages/users-CF1Z-5Al.js | 28 + .../admin/static/assets/tab-BrZ-GZoF.js | 5 + CTFd/themes/admin/static/img/README.md | 2 + CTFd/themes/admin/static/img/favicon.ico | Bin 0 -> 1150 bytes CTFd/themes/admin/static/img/logo.png | Bin 0 -> 16106 bytes CTFd/themes/admin/static/manifest.json | 201 + CTFd/themes/admin/static/sounds/README.md | 1 + .../admin/static/sounds/notification.mp3 | Bin 0 -> 35443 bytes .../admin/static/sounds/notification.webm | Bin 0 -> 13257 bytes .../admin/static/webfonts/fa-brands-400.ttf | Bin 0 -> 207972 bytes .../admin/static/webfonts/fa-brands-400.woff2 | Bin 0 -> 117372 bytes .../admin/static/webfonts/fa-regular-400.ttf | Bin 0 -> 68004 bytes .../static/webfonts/fa-regular-400.woff2 | Bin 0 -> 25452 bytes .../admin/static/webfonts/fa-solid-900.ttf | Bin 0 -> 419720 bytes .../admin/static/webfonts/fa-solid-900.woff2 | Bin 0 -> 156496 bytes .../static/webfonts/fa-v4compatibility.ttf | Bin 0 -> 10832 bytes .../static/webfonts/fa-v4compatibility.woff2 | Bin 0 -> 4792 bytes .../webfonts/lato-latin-400-normal.woff | Bin 0 -> 17452 bytes .../webfonts/lato-latin-400-normal.woff2 | Bin 0 -> 23580 bytes .../webfonts/lato-latin-700-normal.woff | Bin 0 -> 17624 bytes .../webfonts/lato-latin-700-normal.woff2 | Bin 0 -> 23040 bytes .../webfonts/lato-latin-ext-400-normal.woff | Bin 0 -> 3708 bytes .../webfonts/lato-latin-ext-400-normal.woff2 | Bin 0 -> 5472 bytes .../webfonts/lato-latin-ext-700-normal.woff | Bin 0 -> 3744 bytes .../webfonts/lato-latin-ext-700-normal.woff2 | Bin 0 -> 5368 bytes .../webfonts/raleway-cyrillic-500-normal.woff | Bin 0 -> 10872 bytes .../raleway-cyrillic-500-normal.woff2 | Bin 0 -> 12480 bytes .../raleway-cyrillic-ext-500-normal.woff | Bin 0 -> 12172 bytes .../raleway-cyrillic-ext-500-normal.woff2 | Bin 0 -> 12940 bytes .../webfonts/raleway-latin-500-normal.woff | Bin 0 -> 21992 bytes .../webfonts/raleway-latin-500-normal.woff2 | Bin 0 -> 22020 bytes .../raleway-latin-ext-500-normal.woff | Bin 0 -> 16296 bytes .../raleway-latin-ext-500-normal.woff2 | Bin 0 -> 16540 bytes .../raleway-vietnamese-500-normal.woff | Bin 0 -> 5340 bytes .../raleway-vietnamese-500-normal.woff2 | Bin 0 -> 7132 bytes CTFd/themes/admin/templates/base.html | 166 + .../admin/templates/challenges/challenge.html | 209 + .../templates/challenges/challenges.html | 131 + .../admin/templates/challenges/create.html | 73 + .../admin/templates/challenges/new.html | 114 + .../admin/templates/challenges/preview.html | 205 + .../admin/templates/challenges/update.html | 183 + CTFd/themes/admin/templates/config.html | 261 + .../admin/templates/configs/accounts.html | 121 + .../admin/templates/configs/backup.html | 95 + .../admin/templates/configs/brackets.html | 5 + .../admin/templates/configs/challenges.html | 56 + .../themes/admin/templates/configs/email.html | 281 + .../admin/templates/configs/fields.html | 41 + .../admin/templates/configs/general.html | 25 + .../themes/admin/templates/configs/legal.html | 56 + .../admin/templates/configs/localization.html | 19 + CTFd/themes/admin/templates/configs/logo.html | 71 + CTFd/themes/admin/templates/configs/mlc.html | 33 + .../themes/admin/templates/configs/pause.html | 17 + .../templates/configs/registration_code.html | 14 + .../admin/templates/configs/robots.html | 14 + .../admin/templates/configs/sanitize.html | 28 + .../admin/templates/configs/social.html | 23 + .../themes/admin/templates/configs/theme.html | 91 + CTFd/themes/admin/templates/configs/time.html | 215 + .../admin/templates/configs/usermode.html | 72 + .../admin/templates/configs/visibility.html | 51 + CTFd/themes/admin/templates/editor.html | 180 + CTFd/themes/admin/templates/import.html | 58 + CTFd/themes/admin/templates/integrations.html | 27 + CTFd/themes/admin/templates/macros/forms.html | 68 + .../admin/templates/modals/awards/create.html | 136 + .../modals/challenges/challenges.html | 3 + .../templates/modals/challenges/files.html | 2 + .../templates/modals/challenges/flags.html | 2 + .../templates/modals/challenges/hints.html | 1 + .../templates/modals/challenges/next.html | 2 + .../modals/challenges/requirements.html | 3 + .../templates/modals/challenges/solution.html | 2 + .../templates/modals/challenges/solves.html | 24 + .../templates/modals/challenges/tags.html | 2 + .../templates/modals/challenges/topics.html | 2 + .../admin/templates/modals/mail/send.html | 12 + .../templates/modals/teams/addresses.html | 49 + .../admin/templates/modals/teams/captain.html | 22 + .../admin/templates/modals/teams/create.html | 49 + .../admin/templates/modals/teams/edit.html | 46 + .../templates/modals/teams/statistics.html | 23 + .../templates/modals/users/addresses.html | 43 + .../admin/templates/modals/users/create.html | 73 + .../admin/templates/modals/users/edit.html | 62 + .../templates/modals/users/statistics.html | 23 + .../themes/admin/templates/notifications.html | 95 + CTFd/themes/admin/templates/page.html | 7 + CTFd/themes/admin/templates/pages.html | 97 + CTFd/themes/admin/templates/reset.html | 112 + CTFd/themes/admin/templates/scoreboard.html | 63 + .../admin/templates/scoreboard/standings.html | 55 + .../admin/templates/scoreboard/users.html | 55 + CTFd/themes/admin/templates/statistics.html | 241 + CTFd/themes/admin/templates/submissions.html | 173 + CTFd/themes/admin/templates/teams/new.html | 29 + CTFd/themes/admin/templates/teams/team.html | 597 ++ CTFd/themes/admin/templates/teams/teams.html | 168 + CTFd/themes/admin/templates/users/new.html | 29 + CTFd/themes/admin/templates/users/user.html | 464 ++ CTFd/themes/admin/templates/users/users.html | 180 + CTFd/themes/admin/vite.config.js | 90 + CTFd/themes/admin/yarn.lock | 1636 ++++++ .../assets/css/challenge-board.scss | 46 + .../assets/css/codemirror.scss | 1 + .../core-deprecated/assets/css/core.scss | 68 + .../core-deprecated/assets/css/fonts.scss | 9 + .../assets/css/includes/award-icons.scss | 39 + .../assets/css/includes/flag-icons.scss | 511 ++ .../assets/css/includes/jumbotron.css | 4 + .../assets/css/includes/sticky-footer.css | 31 + .../assets/css/includes/utils/min-height.scss | 15 + .../assets/css/includes/utils/opacity.scss | 15 + .../core-deprecated/assets/css/main.scss | 155 + CTFd/themes/core-deprecated/assets/js/CTFd.js | 76 + CTFd/themes/core-deprecated/assets/js/api.js | 3614 ++++++++++++ .../core-deprecated/assets/js/config.js | 5 + .../core-deprecated/assets/js/events.js | 125 + CTFd/themes/core-deprecated/assets/js/ezq.js | 221 + .../themes/core-deprecated/assets/js/fetch.js | 25 + .../core-deprecated/assets/js/graphs.js | 338 ++ .../core-deprecated/assets/js/helpers.js | 127 + .../assets/js/pages/challenges.js | 454 ++ .../core-deprecated/assets/js/pages/events.js | 7 + .../core-deprecated/assets/js/pages/main.js | 27 + .../assets/js/pages/notifications.js | 7 + .../assets/js/pages/scoreboard.js | 179 + .../assets/js/pages/settings.js | 137 + .../core-deprecated/assets/js/pages/setup.js | 169 + .../core-deprecated/assets/js/pages/stats.js | 103 + .../core-deprecated/assets/js/pages/style.js | 8 + .../assets/js/pages/teams/private.js | 172 + .../themes/core-deprecated/assets/js/patch.js | 387 ++ .../core-deprecated/assets/js/styles.js | 45 + .../themes/core-deprecated/assets/js/times.js | 14 + .../themes/core-deprecated/assets/js/utils.js | 324 ++ .../static/css/challenge-board.dev.css | 1 + .../static/css/challenge-board.min.css | 1 + .../static/css/codemirror.dev.css | 2 + .../static/css/codemirror.min.css | 1 + .../core-deprecated/static/css/core.dev.css | 1 + .../core-deprecated/static/css/core.min.css | 1 + .../core-deprecated/static/css/fonts.dev.css | 15 + .../core-deprecated/static/css/fonts.min.css | 14 + .../core-deprecated/static/css/main.dev.css | 9 + .../core-deprecated/static/css/main.min.css | 7 + .../static/fonts/fa-brands-400.eot | Bin 0 -> 134622 bytes .../static/fonts/fa-brands-400.svg | 3637 ++++++++++++ .../static/fonts/fa-brands-400.ttf | Bin 0 -> 134316 bytes .../static/fonts/fa-brands-400.woff | Bin 0 -> 90672 bytes .../static/fonts/fa-brands-400.woff2 | Bin 0 -> 77400 bytes .../static/fonts/fa-regular-400.eot | Bin 0 -> 34350 bytes .../static/fonts/fa-regular-400.svg | 805 +++ .../static/fonts/fa-regular-400.ttf | Bin 0 -> 34052 bytes .../static/fonts/fa-regular-400.woff | Bin 0 -> 16780 bytes .../static/fonts/fa-regular-400.woff2 | Bin 0 -> 13600 bytes .../static/fonts/fa-solid-900.eot | Bin 0 -> 204266 bytes .../static/fonts/fa-solid-900.svg | 5015 +++++++++++++++++ .../static/fonts/fa-solid-900.ttf | Bin 0 -> 203980 bytes .../static/fonts/fa-solid-900.woff | Bin 0 -> 104004 bytes .../static/fonts/fa-solid-900.woff2 | Bin 0 -> 80148 bytes .../static/fonts/fontawesome-webfont.eot | Bin 0 -> 165742 bytes .../static/fonts/fontawesome-webfont.svg | 2671 +++++++++ .../static/fonts/fontawesome-webfont.ttf | Bin 0 -> 165548 bytes .../static/fonts/fontawesome-webfont.woff | Bin 0 -> 98024 bytes .../static/fonts/fontawesome-webfont.woff2 | Bin 0 -> 77160 bytes .../static/fonts/lato-latin-100.woff | Bin 0 -> 27044 bytes .../static/fonts/lato-latin-100.woff2 | Bin 0 -> 21580 bytes .../static/fonts/lato-latin-100italic.woff | Bin 0 -> 21920 bytes .../static/fonts/lato-latin-100italic.woff2 | Bin 0 -> 17024 bytes .../static/fonts/lato-latin-300.woff | Bin 0 -> 30024 bytes .../static/fonts/lato-latin-300.woff2 | Bin 0 -> 23248 bytes .../static/fonts/lato-latin-300italic.woff | Bin 0 -> 22452 bytes .../static/fonts/lato-latin-300italic.woff2 | Bin 0 -> 17640 bytes .../static/fonts/lato-latin-400.woff | Bin 0 -> 28660 bytes .../static/fonts/lato-latin-400.woff2 | Bin 0 -> 23484 bytes .../static/fonts/lato-latin-400italic.woff | Bin 0 -> 29836 bytes .../static/fonts/lato-latin-400italic.woff2 | Bin 0 -> 24440 bytes .../static/fonts/lato-latin-700.woff | Bin 0 -> 28052 bytes .../static/fonts/lato-latin-700.woff2 | Bin 0 -> 22992 bytes .../static/fonts/lato-latin-700italic.woff | Bin 0 -> 29920 bytes .../static/fonts/lato-latin-700italic.woff2 | Bin 0 -> 24428 bytes .../static/fonts/lato-latin-900.woff | Bin 0 -> 27524 bytes .../static/fonts/lato-latin-900.woff2 | Bin 0 -> 22572 bytes .../static/fonts/lato-latin-900italic.woff | Bin 0 -> 28952 bytes .../static/fonts/lato-latin-900italic.woff2 | Bin 0 -> 23696 bytes .../static/fonts/raleway-latin-100.woff | Bin 0 -> 24260 bytes .../static/fonts/raleway-latin-100.woff2 | Bin 0 -> 19884 bytes .../static/fonts/raleway-latin-100italic.woff | Bin 0 -> 25356 bytes .../fonts/raleway-latin-100italic.woff2 | Bin 0 -> 20960 bytes .../static/fonts/raleway-latin-200.woff | Bin 0 -> 24956 bytes .../static/fonts/raleway-latin-200.woff2 | Bin 0 -> 20452 bytes .../static/fonts/raleway-latin-200italic.woff | Bin 0 -> 25892 bytes .../fonts/raleway-latin-200italic.woff2 | Bin 0 -> 21396 bytes .../static/fonts/raleway-latin-300.woff | Bin 0 -> 25328 bytes .../static/fonts/raleway-latin-300.woff2 | Bin 0 -> 20808 bytes .../static/fonts/raleway-latin-300italic.woff | Bin 0 -> 26476 bytes .../fonts/raleway-latin-300italic.woff2 | Bin 0 -> 21808 bytes .../static/fonts/raleway-latin-400.woff | Bin 0 -> 25244 bytes .../static/fonts/raleway-latin-400.woff2 | Bin 0 -> 20724 bytes .../static/fonts/raleway-latin-400italic.woff | Bin 0 -> 26284 bytes .../fonts/raleway-latin-400italic.woff2 | Bin 0 -> 21612 bytes .../static/fonts/raleway-latin-500.woff | Bin 0 -> 25552 bytes .../static/fonts/raleway-latin-500.woff2 | Bin 0 -> 21164 bytes .../static/fonts/raleway-latin-500italic.woff | Bin 0 -> 26180 bytes .../fonts/raleway-latin-500italic.woff2 | Bin 0 -> 21596 bytes .../static/fonts/raleway-latin-600.woff | Bin 0 -> 25396 bytes .../static/fonts/raleway-latin-600.woff2 | Bin 0 -> 20872 bytes .../static/fonts/raleway-latin-600italic.woff | Bin 0 -> 26420 bytes .../fonts/raleway-latin-600italic.woff2 | Bin 0 -> 21744 bytes .../static/fonts/raleway-latin-700.woff | Bin 0 -> 25492 bytes .../static/fonts/raleway-latin-700.woff2 | Bin 0 -> 20864 bytes .../static/fonts/raleway-latin-700italic.woff | Bin 0 -> 26476 bytes .../fonts/raleway-latin-700italic.woff2 | Bin 0 -> 21788 bytes .../static/fonts/raleway-latin-800.woff | Bin 0 -> 25300 bytes .../static/fonts/raleway-latin-800.woff2 | Bin 0 -> 20776 bytes .../static/fonts/raleway-latin-800italic.woff | Bin 0 -> 26496 bytes .../fonts/raleway-latin-800italic.woff2 | Bin 0 -> 21764 bytes .../static/fonts/raleway-latin-900.woff | Bin 0 -> 25668 bytes .../static/fonts/raleway-latin-900.woff2 | Bin 0 -> 21120 bytes .../static/fonts/raleway-latin-900italic.woff | Bin 0 -> 26692 bytes .../fonts/raleway-latin-900italic.woff2 | Bin 0 -> 21980 bytes .../themes/core-deprecated/static/img/ctfd.ai | 4600 +++++++++++++++ .../core-deprecated/static/img/ctfd.svg | 26 + .../static/img/ctfd_transfer.svg | 26 + .../core-deprecated/static/img/favicon.ico | Bin 0 -> 1150 bytes .../core-deprecated/static/img/logo.png | Bin 0 -> 16106 bytes .../core-deprecated/static/img/logo_old.png | Bin 0 -> 15785 bytes .../core-deprecated/static/img/scoreboard.png | Bin 0 -> 370043 bytes .../core-deprecated/static/js/core.dev.js | 157 + .../core-deprecated/static/js/core.min.js | 0 .../static/js/echarts.bundle.dev.js | 15 + .../static/js/echarts.bundle.min.js | 10 + .../core-deprecated/static/js/helpers.dev.js | 63 + .../core-deprecated/static/js/helpers.min.js | 1 + .../static/js/pages/challenges.dev.js | 169 + .../static/js/pages/challenges.min.js | 1 + .../static/js/pages/main.dev.js | 155 + .../static/js/pages/main.min.js | 1 + .../static/js/pages/notifications.dev.js | 169 + .../static/js/pages/notifications.min.js | 1 + .../static/js/pages/scoreboard.dev.js | 169 + .../static/js/pages/scoreboard.min.js | 1 + .../static/js/pages/settings.dev.js | 169 + .../static/js/pages/settings.min.js | 1 + .../static/js/pages/setup.dev.js | 169 + .../static/js/pages/setup.min.js | 1 + .../static/js/pages/stats.dev.js | 181 + .../static/js/pages/stats.min.js | 1 + .../static/js/pages/teams/private.dev.js | 169 + .../static/js/pages/teams/private.min.js | 1 + .../static/js/vendor.bundle.dev.js | 3697 ++++++++++++ .../static/js/vendor.bundle.min.js | 142 + .../core-deprecated/static/sounds/README.md | 17 + .../static/sounds/notification.mp3 | Bin 0 -> 35443 bytes .../static/sounds/notification.webm | Bin 0 -> 13257 bytes .../core-deprecated/templates/base.html | 63 + .../core-deprecated/templates/challenge.html | 156 + .../core-deprecated/templates/challenges.html | 39 + .../templates/components/errors.html | 18 + .../templates/components/navbar.html | 141 + .../core-deprecated/templates/config.html | 12 + .../core-deprecated/templates/confirm.html | 58 + .../core-deprecated/templates/errors/403.html | 20 + .../core-deprecated/templates/errors/404.html | 22 + .../core-deprecated/templates/errors/429.html | 22 + .../core-deprecated/templates/errors/500.html | 21 + .../core-deprecated/templates/errors/502.html | 21 + .../core-deprecated/templates/login.html | 55 + .../templates/macros/forms.html | 46 + .../templates/notifications.html | 29 + .../core-deprecated/templates/page.html | 7 + .../core-deprecated/templates/register.html | 78 + .../templates/reset_password.html | 64 + .../core-deprecated/templates/scoreboard.html | 69 + .../core-deprecated/templates/settings.html | 134 + .../core-deprecated/templates/setup.html | 308 + .../templates/teams/invite.html | 45 + .../templates/teams/join_team.html | 41 + .../templates/teams/new_team.html | 46 + .../templates/teams/private.html | 386 ++ .../templates/teams/public.html | 200 + .../templates/teams/team_enrollment.html | 49 + .../templates/teams/teams.html | 128 + .../templates/users/private.html | 180 + .../templates/users/public.html | 180 + .../templates/users/users.html | 131 + CTFd/themes/core/.github/workflows/build.yml | 30 + .../core/.github/workflows/prettier.yml | 21 + CTFd/themes/core/.gitignore | 24 + CTFd/themes/core/.prettierignore | 2 + CTFd/themes/core/.prettierrc.json | 4 + CTFd/themes/core/LICENSE | 201 + CTFd/themes/core/README.md | 56 + CTFd/themes/core/assets/img/ctfd.ai | 4600 +++++++++++++++ CTFd/themes/core/assets/img/ctfd.svg | 26 + CTFd/themes/core/assets/img/ctfd_transfer.svg | 26 + CTFd/themes/core/assets/img/favicon.ico | Bin 0 -> 1150 bytes CTFd/themes/core/assets/img/logo.png | Bin 0 -> 16106 bytes CTFd/themes/core/assets/img/logo_old.png | Bin 0 -> 15785 bytes CTFd/themes/core/assets/img/scoreboard.png | Bin 0 -> 370043 bytes CTFd/themes/core/assets/js/challenges.js | 364 ++ .../core/assets/js/color_mode_switcher.js | 50 + .../core/assets/js/components/language.js | 20 + CTFd/themes/core/assets/js/index.js | 37 + CTFd/themes/core/assets/js/notifications.js | 33 + CTFd/themes/core/assets/js/page.js | 7 + CTFd/themes/core/assets/js/scoreboard.js | 59 + CTFd/themes/core/assets/js/settings.js | 100 + CTFd/themes/core/assets/js/setup.js | 152 + CTFd/themes/core/assets/js/teams/list.js | 7 + CTFd/themes/core/assets/js/teams/private.js | 209 + CTFd/themes/core/assets/js/teams/public.js | 79 + CTFd/themes/core/assets/js/theme/highlight.js | 12 + CTFd/themes/core/assets/js/theme/styles.js | 27 + CTFd/themes/core/assets/js/theme/times.js | 12 + CTFd/themes/core/assets/js/users/list.js | 7 + CTFd/themes/core/assets/js/users/private.js | 82 + CTFd/themes/core/assets/js/users/public.js | 82 + CTFd/themes/core/assets/js/utils/alerts.js | 8 + CTFd/themes/core/assets/js/utils/clipboard.js | 15 + CTFd/themes/core/assets/js/utils/collapse.js | 8 + .../js/utils/graphs/echarts/categories.js | 107 + .../assets/js/utils/graphs/echarts/index.js | 48 + .../js/utils/graphs/echarts/scoreboard.js | 101 + .../utils/graphs/echarts/solve-percentage.js | 87 + .../js/utils/graphs/echarts/userscore.js | 107 + .../assets/js/utils/graphs/vega/categories.js | 105 + .../assets/js/utils/graphs/vega/scoreboard.js | 53 + .../js/utils/graphs/vega/solve-percentage.js | 83 + .../assets/js/utils/graphs/vega/userscore.js | 60 + CTFd/themes/core/assets/js/utils/math.js | 9 + .../assets/js/utils/notifications/alerts.js | 23 + .../assets/js/utils/notifications/read.js | 19 + .../assets/js/utils/notifications/toasts.js | 28 + CTFd/themes/core/assets/js/utils/objects.js | 11 + CTFd/themes/core/assets/js/utils/tooltips.js | 10 + .../scss/includes/components/_challenge.scss | 40 + .../scss/includes/components/_graphs.scss | 21 + .../scss/includes/components/_jumbotron.scss | 22 + .../includes/components/_sticky-footer.scss | 31 + .../scss/includes/components/_table.scss | 22 + .../scss/includes/icons/_award-icons.scss | 42 + .../scss/includes/icons/_flag-icons.scss | 511 ++ .../assets/scss/includes/utils/_cursors.scss | 7 + .../assets/scss/includes/utils/_fonts.scss | 23 + .../assets/scss/includes/utils/_lolight.scss | 53 + .../scss/includes/utils/_min-height.scss | 19 + .../assets/scss/includes/utils/_opacity.scss | 19 + CTFd/themes/core/assets/scss/main.scss | 55 + CTFd/themes/core/assets/sounds/README.md | 17 + .../core/assets/sounds/notification.mp3 | Bin 0 -> 35443 bytes .../core/assets/sounds/notification.webm | Bin 0 -> 13257 bytes CTFd/themes/core/package.json | 33 + CTFd/themes/core/postcss.config.js | 0 .../core/static/assets/challenges.6ea6caae.js | 1 + .../core/static/assets/clipboard.b2a3c241.js | 1 + .../assets/color_mode_switcher.52334129.js | 1 + .../core/static/assets/echarts.128204f2.js | 56 + .../core/static/assets/index.1cf73b05.js | 1 + .../core/static/assets/index.9b9a8697.js | 31 + .../core/static/assets/main.e9ec7884.css | 9 + .../static/assets/notifications.da39e2fc.js | 1 + .../core/static/assets/page.94550e0c.js | 1 + .../core/static/assets/scoreboard.024f37bd.js | 1 + .../core/static/assets/settings.13fb7a9e.js | 1 + .../core/static/assets/setup.8ef4208b.js | 1 + .../core/static/assets/teams_list.3872f430.js | 1 + .../static/assets/teams_private.ccdfcaf5.js | 1 + .../static/assets/teams_public.866f8286.js | 1 + .../core/static/assets/users_list.35657373.js | 1 + .../static/assets/users_private.d43c8fa2.js | 1 + .../static/assets/users_public.9b4bfe95.js | 1 + .../core/static/assets/userscore.fadbe0bc.js | 1 + CTFd/themes/core/static/img/ctfd.ai | 4600 +++++++++++++++ CTFd/themes/core/static/img/ctfd.svg | 26 + CTFd/themes/core/static/img/ctfd_transfer.svg | 26 + CTFd/themes/core/static/img/favicon.ico | Bin 0 -> 1150 bytes CTFd/themes/core/static/img/logo.png | Bin 0 -> 16106 bytes CTFd/themes/core/static/img/logo_old.png | Bin 0 -> 15785 bytes CTFd/themes/core/static/img/scoreboard.png | Bin 0 -> 370043 bytes CTFd/themes/core/static/manifest.json | 151 + CTFd/themes/core/static/sounds/README.md | 17 + .../core/static/sounds/notification.mp3 | Bin 0 -> 35443 bytes .../core/static/sounds/notification.webm | Bin 0 -> 13257 bytes .../core/static/webfonts/fa-brands-400.ttf | Bin 0 -> 207972 bytes .../core/static/webfonts/fa-brands-400.woff2 | Bin 0 -> 117372 bytes .../core/static/webfonts/fa-regular-400.ttf | Bin 0 -> 68004 bytes .../core/static/webfonts/fa-regular-400.woff2 | Bin 0 -> 25452 bytes .../core/static/webfonts/fa-solid-900.ttf | Bin 0 -> 419720 bytes .../core/static/webfonts/fa-solid-900.woff2 | Bin 0 -> 156496 bytes .../static/webfonts/fa-v4compatibility.ttf | Bin 0 -> 10832 bytes .../static/webfonts/fa-v4compatibility.woff2 | Bin 0 -> 4792 bytes .../static/webfonts/lato-all-400-normal.woff | Bin 0 -> 34020 bytes .../static/webfonts/lato-all-700-normal.woff | Bin 0 -> 33296 bytes .../webfonts/lato-latin-400-normal.woff | Bin 0 -> 28648 bytes .../webfonts/lato-latin-400-normal.woff2 | Bin 0 -> 23580 bytes .../webfonts/lato-latin-700-normal.woff | Bin 0 -> 28044 bytes .../webfonts/lato-latin-700-normal.woff2 | Bin 0 -> 23040 bytes .../webfonts/lato-latin-ext-400-normal.woff | Bin 0 -> 30908 bytes .../webfonts/lato-latin-ext-400-normal.woff2 | Bin 0 -> 5472 bytes .../webfonts/lato-latin-ext-700-normal.woff | Bin 0 -> 30356 bytes .../webfonts/lato-latin-ext-700-normal.woff2 | Bin 0 -> 5368 bytes .../webfonts/raleway-all-400-normal.woff | Bin 0 -> 68188 bytes .../webfonts/raleway-cyrillic-400-normal.woff | Bin 0 -> 35652 bytes .../raleway-cyrillic-400-normal.woff2 | Bin 0 -> 11820 bytes .../raleway-cyrillic-ext-400-normal.woff | Bin 0 -> 44112 bytes .../raleway-cyrillic-ext-400-normal.woff2 | Bin 0 -> 12540 bytes .../webfonts/raleway-latin-400-normal.woff | Bin 0 -> 25804 bytes .../webfonts/raleway-latin-400-normal.woff2 | Bin 0 -> 21028 bytes .../raleway-latin-ext-400-normal.woff | Bin 0 -> 38820 bytes .../raleway-latin-ext-400-normal.woff2 | Bin 0 -> 15004 bytes .../raleway-vietnamese-400-normal.woff | Bin 0 -> 28964 bytes .../raleway-vietnamese-400-normal.woff2 | Bin 0 -> 6376 bytes CTFd/themes/core/templates/base.html | 72 + CTFd/themes/core/templates/challenge.html | 359 ++ CTFd/themes/core/templates/challenges.html | 75 + .../core/templates/components/errors.html | 19 + .../core/templates/components/navbar.html | 211 + .../templates/components/notifications.html | 33 + .../core/templates/components/snackbar.html | 16 + CTFd/themes/core/templates/config.html | 47 + CTFd/themes/core/templates/confirm.html | 61 + CTFd/themes/core/templates/errors/403.html | 18 + CTFd/themes/core/templates/errors/404.html | 19 + CTFd/themes/core/templates/errors/429.html | 20 + CTFd/themes/core/templates/errors/500.html | 19 + CTFd/themes/core/templates/errors/502.html | 19 + CTFd/themes/core/templates/login.html | 61 + CTFd/themes/core/templates/macros/forms.html | 99 + CTFd/themes/core/templates/notifications.html | 34 + CTFd/themes/core/templates/page.html | 11 + CTFd/themes/core/templates/register.html | 92 + .../themes/core/templates/reset_password.html | 66 + CTFd/themes/core/templates/scoreboard.html | 68 + CTFd/themes/core/templates/settings.html | 266 + CTFd/themes/core/templates/setup.html | 388 ++ CTFd/themes/core/templates/teams/invite.html | 39 + .../core/templates/teams/join_team.html | 37 + .../themes/core/templates/teams/new_team.html | 41 + CTFd/themes/core/templates/teams/private.html | 500 ++ CTFd/themes/core/templates/teams/public.html | 269 + .../core/templates/teams/team_enrollment.html | 44 + CTFd/themes/core/templates/teams/teams.html | 153 + CTFd/themes/core/templates/users/private.html | 223 + CTFd/themes/core/templates/users/public.html | 232 + CTFd/themes/core/templates/users/users.html | 142 + CTFd/themes/core/vite.config.js | 74 + CTFd/themes/core/yarn.lock | 852 +++ CTFd/translations/ar/LC_MESSAGES/messages.mo | Bin 0 -> 13540 bytes CTFd/translations/ar/LC_MESSAGES/messages.po | 875 +++ CTFd/translations/bg/LC_MESSAGES/messages.mo | Bin 0 -> 608 bytes CTFd/translations/bg/LC_MESSAGES/messages.po | 859 +++ CTFd/translations/ca/LC_MESSAGES/messages.mo | Bin 0 -> 11757 bytes CTFd/translations/ca/LC_MESSAGES/messages.po | 859 +++ CTFd/translations/cs/LC_MESSAGES/messages.mo | Bin 0 -> 703 bytes CTFd/translations/cs/LC_MESSAGES/messages.po | 867 +++ CTFd/translations/de/LC_MESSAGES/messages.mo | Bin 0 -> 11702 bytes CTFd/translations/de/LC_MESSAGES/messages.po | 859 +++ CTFd/translations/el/LC_MESSAGES/messages.mo | Bin 0 -> 16191 bytes CTFd/translations/el/LC_MESSAGES/messages.po | 859 +++ CTFd/translations/es/LC_MESSAGES/messages.mo | Bin 0 -> 11911 bytes CTFd/translations/es/LC_MESSAGES/messages.po | 859 +++ CTFd/translations/fi/LC_MESSAGES/messages.mo | Bin 0 -> 11287 bytes CTFd/translations/fi/LC_MESSAGES/messages.po | 859 +++ CTFd/translations/fr/LC_MESSAGES/messages.mo | Bin 0 -> 11919 bytes CTFd/translations/fr/LC_MESSAGES/messages.po | 859 +++ CTFd/translations/he/LC_MESSAGES/messages.mo | Bin 0 -> 14362 bytes CTFd/translations/he/LC_MESSAGES/messages.po | 867 +++ CTFd/translations/it/LC_MESSAGES/messages.mo | Bin 0 -> 11717 bytes CTFd/translations/it/LC_MESSAGES/messages.po | 859 +++ CTFd/translations/ja/LC_MESSAGES/messages.mo | Bin 0 -> 12523 bytes CTFd/translations/ja/LC_MESSAGES/messages.po | 855 +++ CTFd/translations/ko/LC_MESSAGES/messages.mo | Bin 0 -> 11628 bytes CTFd/translations/ko/LC_MESSAGES/messages.po | 855 +++ CTFd/translations/lt/LC_MESSAGES/messages.mo | Bin 0 -> 781 bytes CTFd/translations/lt/LC_MESSAGES/messages.po | 867 +++ CTFd/translations/pl/LC_MESSAGES/messages.mo | Bin 0 -> 11758 bytes CTFd/translations/pl/LC_MESSAGES/messages.po | 867 +++ .../pt_BR/LC_MESSAGES/messages.mo | Bin 0 -> 11586 bytes .../pt_BR/LC_MESSAGES/messages.po | 859 +++ CTFd/translations/ro/LC_MESSAGES/messages.mo | Bin 0 -> 11814 bytes CTFd/translations/ro/LC_MESSAGES/messages.po | 863 +++ CTFd/translations/ru/LC_MESSAGES/messages.mo | Bin 0 -> 16045 bytes CTFd/translations/ru/LC_MESSAGES/messages.po | 867 +++ CTFd/translations/sk/LC_MESSAGES/messages.mo | Bin 0 -> 11707 bytes CTFd/translations/sk/LC_MESSAGES/messages.po | 867 +++ CTFd/translations/sl/LC_MESSAGES/messages.mo | Bin 0 -> 11297 bytes CTFd/translations/sl/LC_MESSAGES/messages.po | 867 +++ CTFd/translations/sv/LC_MESSAGES/messages.mo | Bin 0 -> 11131 bytes CTFd/translations/sv/LC_MESSAGES/messages.po | 859 +++ CTFd/translations/uz/LC_MESSAGES/messages.mo | Bin 0 -> 13450 bytes CTFd/translations/uz/LC_MESSAGES/messages.po | 859 +++ CTFd/translations/vi/LC_MESSAGES/messages.mo | Bin 0 -> 12000 bytes CTFd/translations/vi/LC_MESSAGES/messages.po | 855 +++ .../zh_Hans_CN/LC_MESSAGES/messages.mo | Bin 0 -> 10405 bytes .../zh_Hans_CN/LC_MESSAGES/messages.po | 855 +++ .../zh_Hant_TW/LC_MESSAGES/messages.mo | Bin 0 -> 10646 bytes .../zh_Hant_TW/LC_MESSAGES/messages.po | 855 +++ CTFd/users.py | 82 + CTFd/utils/__init__.py | 110 + CTFd/utils/challenges/__init__.py | 169 + CTFd/utils/config/__init__.py | 98 + CTFd/utils/config/integrations.py | 14 + CTFd/utils/config/pages.py | 79 + CTFd/utils/config/visibility.py | 51 + CTFd/utils/countries/__init__.py | 275 + CTFd/utils/countries/geoip.py | 23 + CTFd/utils/crypto/__init__.py | 19 + CTFd/utils/csv/__init__.py | 472 ++ CTFd/utils/dates/__init__.py | 87 + CTFd/utils/decorators/__init__.py | 234 + CTFd/utils/decorators/modes.py | 26 + CTFd/utils/decorators/visibility.py | 123 + CTFd/utils/email/__init__.py | 180 + CTFd/utils/email/mailgun.py | 8 + 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 | 8 + CTFd/utils/encoding/__init__.py | 48 + CTFd/utils/events/__init__.py | 116 + CTFd/utils/exports/__init__.py | 497 ++ CTFd/utils/exports/databases.py | 12 + CTFd/utils/exports/freeze.py | 12 + CTFd/utils/exports/serializers.py | 74 + CTFd/utils/formatters/__init__.py | 23 + CTFd/utils/health/__init__.py | 19 + CTFd/utils/helpers/__init__.py | 53 + CTFd/utils/helpers/models.py | 26 + CTFd/utils/humanize/__init__.py | 0 CTFd/utils/humanize/numbers.py | 6 + CTFd/utils/humanize/words.py | 5 + CTFd/utils/initialization/__init__.py | 385 ++ CTFd/utils/logging/__init__.py | 19 + CTFd/utils/migrations/__init__.py | 62 + CTFd/utils/modes/__init__.py | 41 + CTFd/utils/notifications/__init__.py | 0 CTFd/utils/plugins/__init__.py | 81 + CTFd/utils/scoreboard/__init__.py | 72 + CTFd/utils/scores/__init__.py | 333 ++ CTFd/utils/security/__init__.py | 0 CTFd/utils/security/auth.py | 99 + CTFd/utils/security/csrf.py | 7 + CTFd/utils/security/email.py | 42 + CTFd/utils/security/passwords.py | 24 + CTFd/utils/security/sanitize.py | 98 + CTFd/utils/security/signing.py | 53 + CTFd/utils/sessions/__init__.py | 133 + CTFd/utils/social/__init__.py | 213 + CTFd/utils/updates/__init__.py | 78 + CTFd/utils/uploads/__init__.py | 98 + CTFd/utils/uploads/uploaders.py | 249 + CTFd/utils/user/__init__.py | 247 + CTFd/utils/validators/__init__.py | 52 + CTFd/views.py | 559 ++ Dockerfile | 55 + LICENSE | 202 + Makefile | 48 + README.md | 90 + SECURITY.md | 5 + Vagrantfile | 73 + babel.cfg | 2 + conf/nginx/http.conf | 51 + crowdin.yml | 6 + development.txt | 16 + docker-compose.yml | 105 + docker-entrypoint.sh | 39 + export.py | 28 + import.py | 14 + linting.txt | 4 + manage.py | 11 + messages.pot | 877 +++ migrations/1_2_0_upgrade_2_0_0.py | 275 + migrations/alembic.ini | 45 + migrations/env.py | 96 + migrations/script.py.mako | 24 + .../0366ba6575ca_add_table_for_comments.py | 47 + .../07dfbe5e1edc_add_format_to_pages.py | 28 + .../versions/080d29b15cd3_add_tokens_table.py | 34 + ...57c1_add_language_column_to_users_table.py | 23 + ...093835a1051_add_default_email_templates.py | 70 + ...6790bc3c_convert_rating_values_to_votes.py | 45 + .../364b4efa1686_add_ratings_table.py | 42 + ..._enable_millisecond_precision_in_mysql_.py | 46 + ...b59d011_add_next_id_to_challenges_table.py | 32 + .../4e4d5a9ea000_add_type_to_awards.py | 32 + ...3eeed9a9d_add_attribution_to_challenges.py | 24 + ...23b100da8_add_target_column_to_tracking.py | 28 + ...2cb_add_sha1sum_field_to_files_require_.py | 23 + .../5c98d9253f56_rename_core_beta_to_core.py | 119 + ...dd_connection_info_column_to_challenges.py | 28 + .../62bf576b2cd3_add_solutions_table.py | 43 + ...2d728ad7da_add_change_password_to_users.py | 28 + ...b6de598_add_dynamic_scoring_columns_to_.py | 36 + ...0014_add_fields_and_fieldentries_tables.py | 53 + .../versions/8369118943a1_initial_revision.py | 249 + .../9889b8c53673_add_brackets_table.py | 51 + ..._add_description_column_to_tokens_table.py | 23 + .../a02c5bf43407_add_link_target_to_pages.py | 26 + ...32_add_theme_code_injections_to_configs.py | 46 + .../a49ad66aa0f1_add_title_to_hint.py | 23 + ...4d_add_ondelete_cascade_to_foreign_keys.py | 298 + ...5551cd26764_add_captain_column_to_teams.py | 57 + ..._add_topics_and_challenge_topics_tables.py | 46 + ...96c97449_add_logic_column_to_challenges.py | 30 + ping.py | 35 + populate.py | 498 ++ prepare.sh | 4 + requirements.in | 36 + requirements.txt | 203 + scripts/install_docker.sh | 38 + scripts/pip-compile.sh | 10 + serve.py | 43 + setup.cfg | 5 + tests/__init__.py | 0 tests/admin/__init__.py | 0 tests/admin/test_challenges.py | 95 + tests/admin/test_config.py | 253 + tests/admin/test_csv.py | 143 + tests/admin/test_fields.py | 94 + tests/admin/test_notifications.py | 0 tests/admin/test_pages.py | 95 + tests/admin/test_scoreboard.py | 54 + tests/admin/test_statistics.py | 0 tests/admin/test_submissions.py | 41 + tests/admin/test_teams.py | 0 tests/admin/test_users.py | 49 + tests/admin/test_views.py | 82 + tests/api/__init__.py | 0 tests/api/test_tokens.py | 105 + tests/api/v1/__init__.py | 0 .../requirements/test_requirements.py | 171 + tests/api/v1/statistics/__init__.py | 0 tests/api/v1/statistics/test_scores.py | 48 + tests/api/v1/teams/__init__.py | 0 tests/api/v1/teams/test_scoring.py | 90 + tests/api/v1/teams/test_team_members.py | 152 + tests/api/v1/teams/test_teams.py | 92 + tests/api/v1/test_awards.py | 121 + tests/api/v1/test_brackets.py | 122 + tests/api/v1/test_challenges.py | 1475 +++++ tests/api/v1/test_comments.py | 108 + tests/api/v1/test_config.py | 219 + tests/api/v1/test_csrf.py | 44 + tests/api/v1/test_exports.py | 49 + tests/api/v1/test_fields.py | 416 ++ tests/api/v1/test_files.py | 242 + tests/api/v1/test_flags.py | 240 + tests/api/v1/test_hints.py | 149 + tests/api/v1/test_notifications.py | 81 + tests/api/v1/test_pages.py | 126 + tests/api/v1/test_scoreboard.py | 193 + tests/api/v1/test_solutions.py | 1023 ++++ tests/api/v1/test_submissions.py | 230 + tests/api/v1/test_tags.py | 103 + tests/api/v1/test_teams.py | 891 +++ tests/api/v1/test_tokens.py | 123 + tests/api/v1/test_topics.py | 132 + tests/api/v1/test_users.py | 970 ++++ tests/api/v1/user/__init__.py | 0 tests/api/v1/user/test_admin_access.py | 48 + tests/api/v1/user/test_challenges.py | 393 ++ tests/api/v1/user/test_hints.py | 285 + tests/api/v1/users/__init__.py | 0 tests/api/v1/users/test_scoring.py | 89 + tests/api/v1/users/test_users.py | 113 + tests/brackets/test_brackets.py | 59 + tests/cache/__init__.py | 0 tests/cache/test_cache.py | 110 + tests/cache/test_challenges.py | 128 + tests/challenges/__init__.py | 0 tests/challenges/test_base_challenge.py | 61 + tests/challenges/test_challenge_logic.py | 275 + tests/challenges/test_challenge_types.py | 76 + tests/challenges/test_dynamic.py | 402 ++ tests/challenges/test_ratings.py | 598 ++ tests/challenges/test_standard_dynamic.py | 836 +++ tests/constants/test_constants.py | 52 + tests/constants/time.py | 9 + tests/helpers.py | 624 ++ tests/models/test_model_utils.py | 23 + tests/oauth/__init__.py | 0 tests/oauth/test_redirect.py | 113 + tests/oauth/test_teams.py | 71 + tests/oauth/test_users.py | 48 + tests/teams/__init__.py | 0 tests/teams/test_auth.py | 245 + tests/teams/test_challenges.py | 65 + tests/teams/test_fields.py | 396 ++ tests/teams/test_hidden_team_scores.py | 91 + tests/teams/test_hints.py | 138 + tests/teams/test_invites.py | 81 + tests/teams/test_scoreboard.py | 33 + tests/teams/test_teams.py | 251 + tests/test_config.py | 450 ++ tests/test_legal.py | 27 + tests/test_plugin_utils.py | 226 + tests/test_setup.py | 31 + tests/test_share.py | 112 + tests/test_themes.py | 227 + tests/test_views.py | 552 ++ tests/users/__init__.py | 0 tests/users/test_auth.py | 837 +++ tests/users/test_challenges.py | 950 ++++ tests/users/test_fields.py | 319 ++ tests/users/test_hints.py | 289 + tests/users/test_profile.py | 37 + tests/users/test_scoreboard.py | 365 ++ tests/users/test_settings.py | 101 + tests/users/test_setup.py | 56 + tests/users/test_submissions.py | 105 + tests/users/test_users.py | 151 + tests/utils/__init__.py | 24 + tests/utils/test_ctftime.py | 239 + tests/utils/test_email.py | 561 ++ tests/utils/test_encoding.py | 51 + tests/utils/test_events.py | 250 + tests/utils/test_exports.py | 104 + tests/utils/test_formatters.py | 13 + tests/utils/test_humanize.py | 19 + tests/utils/test_markdown.py | 12 + tests/utils/test_passwords.py | 19 + tests/utils/test_plugins.py | 57 + tests/utils/test_ratelimit.py | 24 + tests/utils/test_sanitize.py | 364 ++ tests/utils/test_sessions.py | 97 + tests/utils/test_updates.py | 104 + tests/utils/test_uploaders.py | 97 + tests/utils/test_validators.py | 36 + wsgi.py | 14 + 1047 files changed, 150349 insertions(+) create mode 100644 .codecov.yml create mode 100644 .dockerignore create mode 100644 .eslintrc.js create mode 100644 .flaskenv create mode 100644 .gitattributes create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE.md create mode 100644 .github/workflows/docker-build.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/mariadb.yml create mode 100644 .github/workflows/mirror-core-theme.yml create mode 100644 .github/workflows/mysql.yml create mode 100644 .github/workflows/mysql8.yml create mode 100644 .github/workflows/postgres.yml create mode 100644 .github/workflows/sqlite.yml create mode 100644 .github/workflows/verify-themes.yml create mode 100644 .gitignore create mode 100644 .isort.cfg create mode 100644 .prettierignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 CTFd/__init__.py create mode 100644 CTFd/admin/__init__.py create mode 100644 CTFd/admin/challenges.py create mode 100644 CTFd/admin/notifications.py create mode 100644 CTFd/admin/pages.py create mode 100644 CTFd/admin/scoreboard.py create mode 100644 CTFd/admin/statistics.py create mode 100644 CTFd/admin/submissions.py create mode 100644 CTFd/admin/teams.py create mode 100644 CTFd/admin/users.py create mode 100644 CTFd/api/__init__.py create mode 100644 CTFd/api/v1/__init__.py create mode 100644 CTFd/api/v1/awards.py create mode 100644 CTFd/api/v1/brackets.py create mode 100644 CTFd/api/v1/challenges.py create mode 100644 CTFd/api/v1/comments.py create mode 100644 CTFd/api/v1/config.py create mode 100644 CTFd/api/v1/exports.py create mode 100644 CTFd/api/v1/files.py create mode 100644 CTFd/api/v1/flags.py create mode 100644 CTFd/api/v1/helpers/__init__.py create mode 100644 CTFd/api/v1/helpers/models.py create mode 100644 CTFd/api/v1/helpers/request.py create mode 100644 CTFd/api/v1/helpers/schemas.py create mode 100644 CTFd/api/v1/hints.py create mode 100644 CTFd/api/v1/notifications.py create mode 100644 CTFd/api/v1/pages.py create mode 100644 CTFd/api/v1/schemas/__init__.py create mode 100644 CTFd/api/v1/scoreboard.py create mode 100644 CTFd/api/v1/shares.py create mode 100644 CTFd/api/v1/solutions.py create mode 100644 CTFd/api/v1/statistics/__init__.py create mode 100644 CTFd/api/v1/statistics/challenges.py create mode 100644 CTFd/api/v1/statistics/scores.py create mode 100644 CTFd/api/v1/statistics/submissions.py create mode 100644 CTFd/api/v1/statistics/teams.py create mode 100644 CTFd/api/v1/statistics/users.py create mode 100644 CTFd/api/v1/submissions.py create mode 100644 CTFd/api/v1/tags.py create mode 100644 CTFd/api/v1/teams.py create mode 100644 CTFd/api/v1/tokens.py create mode 100644 CTFd/api/v1/topics.py create mode 100644 CTFd/api/v1/unlocks.py create mode 100644 CTFd/api/v1/users.py create mode 100644 CTFd/auth.py create mode 100644 CTFd/cache/__init__.py create mode 100644 CTFd/challenges.py create mode 100644 CTFd/cli/__init__.py create mode 100644 CTFd/config.ini create mode 100644 CTFd/config.py create mode 100644 CTFd/constants/__init__.py create mode 100644 CTFd/constants/assets.py create mode 100644 CTFd/constants/config.py create mode 100644 CTFd/constants/email.py create mode 100644 CTFd/constants/languages.py create mode 100644 CTFd/constants/options.py create mode 100644 CTFd/constants/plugins.py create mode 100644 CTFd/constants/sessions.py create mode 100644 CTFd/constants/setup.py create mode 100644 CTFd/constants/static.py create mode 100644 CTFd/constants/teams.py create mode 100644 CTFd/constants/themes.py create mode 100644 CTFd/constants/users.py create mode 100644 CTFd/errors.py create mode 100644 CTFd/events/__init__.py create mode 100644 CTFd/exceptions/__init__.py create mode 100644 CTFd/exceptions/challenges.py create mode 100644 CTFd/exceptions/email.py create mode 100644 CTFd/fonts/OFL.txt create mode 100644 CTFd/fonts/OpenSans-Bold.ttf create mode 100644 CTFd/forms/__init__.py create mode 100644 CTFd/forms/auth.py create mode 100644 CTFd/forms/awards.py create mode 100644 CTFd/forms/challenges.py create mode 100644 CTFd/forms/config.py create mode 100644 CTFd/forms/email.py create mode 100644 CTFd/forms/fields.py create mode 100644 CTFd/forms/language.py create mode 100644 CTFd/forms/notifications.py create mode 100644 CTFd/forms/pages.py create mode 100644 CTFd/forms/self.py create mode 100644 CTFd/forms/setup.py create mode 100644 CTFd/forms/submissions.py create mode 100644 CTFd/forms/teams.py create mode 100644 CTFd/forms/users.py create mode 100644 CTFd/logs/.gitkeep create mode 100644 CTFd/models/__init__.py create mode 100644 CTFd/plugins/__init__.py create mode 100644 CTFd/plugins/challenges/__init__.py create mode 100644 CTFd/plugins/challenges/assets/create.html create mode 100644 CTFd/plugins/challenges/assets/create.js create mode 100644 CTFd/plugins/challenges/assets/update.html create mode 100644 CTFd/plugins/challenges/assets/update.js create mode 100644 CTFd/plugins/challenges/assets/view.html create mode 100644 CTFd/plugins/challenges/assets/view.js create mode 100644 CTFd/plugins/challenges/decay.py create mode 100644 CTFd/plugins/challenges/logic.py create mode 100644 CTFd/plugins/ctfd-whale/.gitignore create mode 100644 CTFd/plugins/ctfd-whale/CHANGELOG.md create mode 100644 CTFd/plugins/ctfd-whale/LICENSE create mode 100644 CTFd/plugins/ctfd-whale/README.md create mode 100644 CTFd/plugins/ctfd-whale/README.zh-cn.md create mode 100644 CTFd/plugins/ctfd-whale/__init__.py create mode 100644 CTFd/plugins/ctfd-whale/api.py create mode 100644 CTFd/plugins/ctfd-whale/assets/config.js create mode 100644 CTFd/plugins/ctfd-whale/assets/containers.js create mode 100644 CTFd/plugins/ctfd-whale/assets/create.html create mode 100644 CTFd/plugins/ctfd-whale/assets/create.js create mode 100644 CTFd/plugins/ctfd-whale/assets/update.html create mode 100644 CTFd/plugins/ctfd-whale/assets/update.js create mode 100644 CTFd/plugins/ctfd-whale/assets/view.html create mode 100644 CTFd/plugins/ctfd-whale/assets/view.js create mode 100644 CTFd/plugins/ctfd-whale/challenge_type.py create mode 100644 CTFd/plugins/ctfd-whale/decorators.py create mode 100644 CTFd/plugins/ctfd-whale/docker-compose.example.yml create mode 100644 CTFd/plugins/ctfd-whale/docs/advanced.md create mode 100644 CTFd/plugins/ctfd-whale/docs/advanced.zh-cn.md create mode 100644 CTFd/plugins/ctfd-whale/docs/imgs/arch.png create mode 100644 CTFd/plugins/ctfd-whale/docs/imgs/whale-config1.png create mode 100644 CTFd/plugins/ctfd-whale/docs/imgs/whale-config2.png create mode 100644 CTFd/plugins/ctfd-whale/docs/imgs/whale-config3.png create mode 100644 CTFd/plugins/ctfd-whale/docs/install.md create mode 100644 CTFd/plugins/ctfd-whale/docs/install.zh-cn.md create mode 100644 CTFd/plugins/ctfd-whale/models.py create mode 100644 CTFd/plugins/ctfd-whale/requirements.txt create mode 100644 CTFd/plugins/ctfd-whale/templates/config/base.router.config.html create mode 100644 CTFd/plugins/ctfd-whale/templates/config/challenges.config.html create mode 100644 CTFd/plugins/ctfd-whale/templates/config/docker.config.html create mode 100644 CTFd/plugins/ctfd-whale/templates/config/frp.router.config.html create mode 100644 CTFd/plugins/ctfd-whale/templates/config/limits.config.html create mode 100644 CTFd/plugins/ctfd-whale/templates/config/trp.router.config.html create mode 100644 CTFd/plugins/ctfd-whale/templates/containers/card.containers.html create mode 100644 CTFd/plugins/ctfd-whale/templates/containers/list.containers.html create mode 100644 CTFd/plugins/ctfd-whale/templates/whale_base.html create mode 100644 CTFd/plugins/ctfd-whale/templates/whale_config.html create mode 100644 CTFd/plugins/ctfd-whale/templates/whale_containers.html create mode 100644 CTFd/plugins/ctfd-whale/utils/__init__.py create mode 100644 CTFd/plugins/ctfd-whale/utils/cache.py create mode 100644 CTFd/plugins/ctfd-whale/utils/checks.py create mode 100644 CTFd/plugins/ctfd-whale/utils/control.py create mode 100644 CTFd/plugins/ctfd-whale/utils/db.py create mode 100644 CTFd/plugins/ctfd-whale/utils/docker.py create mode 100644 CTFd/plugins/ctfd-whale/utils/exceptions.py create mode 100644 CTFd/plugins/ctfd-whale/utils/routers/__init__.py create mode 100644 CTFd/plugins/ctfd-whale/utils/routers/base.py create mode 100644 CTFd/plugins/ctfd-whale/utils/routers/frp.py create mode 100644 CTFd/plugins/ctfd-whale/utils/routers/trp.py create mode 100644 CTFd/plugins/ctfd-whale/utils/setup.py create mode 100644 CTFd/plugins/dynamic_challenges/.gitignore create mode 100644 CTFd/plugins/dynamic_challenges/README.md create mode 100644 CTFd/plugins/dynamic_challenges/__init__.py create mode 100644 CTFd/plugins/dynamic_challenges/assets/create.html create mode 100644 CTFd/plugins/dynamic_challenges/assets/create.js create mode 100644 CTFd/plugins/dynamic_challenges/assets/update.html create mode 100644 CTFd/plugins/dynamic_challenges/assets/update.js create mode 100644 CTFd/plugins/dynamic_challenges/assets/view.html create mode 100644 CTFd/plugins/dynamic_challenges/assets/view.js create mode 100644 CTFd/plugins/dynamic_challenges/decay.py create mode 100644 CTFd/plugins/dynamic_challenges/function.png create mode 100644 CTFd/plugins/dynamic_challenges/migrations/93284ed9c099_add_dynamic_prefix_to_dynamic_challenge_.py create mode 100644 CTFd/plugins/dynamic_challenges/migrations/b37fb68807ea_add_cascading_delete_to_dynamic_.py create mode 100644 CTFd/plugins/dynamic_challenges/migrations/eb68f277ab61_add_func_column_to_dynamic_challenges.py create mode 100644 CTFd/plugins/flags/__init__.py create mode 100644 CTFd/plugins/flags/assets/regex/create.html create mode 100644 CTFd/plugins/flags/assets/regex/edit.html create mode 100644 CTFd/plugins/flags/assets/static/create.html create mode 100644 CTFd/plugins/flags/assets/static/edit.html create mode 100644 CTFd/plugins/flags/tests/__init__.py create mode 100644 CTFd/plugins/flags/tests/test_flags.py create mode 100644 CTFd/plugins/migrations.py create mode 100644 CTFd/schemas/__init__.py create mode 100644 CTFd/schemas/awards.py create mode 100644 CTFd/schemas/brackets.py create mode 100644 CTFd/schemas/challenges.py create mode 100644 CTFd/schemas/comments.py create mode 100644 CTFd/schemas/config.py create mode 100644 CTFd/schemas/fields.py create mode 100644 CTFd/schemas/files.py create mode 100644 CTFd/schemas/flags.py create mode 100644 CTFd/schemas/hints.py create mode 100644 CTFd/schemas/notifications.py create mode 100644 CTFd/schemas/pages.py create mode 100644 CTFd/schemas/ratings.py create mode 100644 CTFd/schemas/solutions.py create mode 100644 CTFd/schemas/submissions.py create mode 100644 CTFd/schemas/tags.py create mode 100644 CTFd/schemas/teams.py create mode 100644 CTFd/schemas/tokens.py create mode 100644 CTFd/schemas/topics.py create mode 100644 CTFd/schemas/unlocks.py create mode 100644 CTFd/schemas/users.py create mode 100644 CTFd/scoreboard.py create mode 100644 CTFd/share.py create mode 100644 CTFd/teams.py create mode 100644 CTFd/themes/admin/assets/css/admin.scss create mode 100644 CTFd/themes/admin/assets/css/challenge-board.scss create mode 100644 CTFd/themes/admin/assets/css/codemirror.scss create mode 100644 CTFd/themes/admin/assets/css/fonts.scss create mode 100644 CTFd/themes/admin/assets/css/includes/award-icons.scss create mode 100644 CTFd/themes/admin/assets/css/includes/easymde.scss create mode 100644 CTFd/themes/admin/assets/css/includes/flag-icons.scss create mode 100644 CTFd/themes/admin/assets/css/includes/jumbotron.css create mode 100644 CTFd/themes/admin/assets/css/includes/min-height.scss create mode 100644 CTFd/themes/admin/assets/css/includes/opacity.scss create mode 100644 CTFd/themes/admin/assets/css/includes/sticky-footer.css create mode 100644 CTFd/themes/admin/assets/css/main.scss create mode 100644 CTFd/themes/admin/assets/js/challenges/challenge.js create mode 100644 CTFd/themes/admin/assets/js/challenges/new.js create mode 100644 CTFd/themes/admin/assets/js/challenges/tags.js create mode 100644 CTFd/themes/admin/assets/js/compat/CTFd.js create mode 100644 CTFd/themes/admin/assets/js/compat/api.js create mode 100644 CTFd/themes/admin/assets/js/compat/config.js create mode 100644 CTFd/themes/admin/assets/js/compat/events.js create mode 100644 CTFd/themes/admin/assets/js/compat/ezq.js create mode 100644 CTFd/themes/admin/assets/js/compat/fetch.js create mode 100644 CTFd/themes/admin/assets/js/compat/format.js create mode 100644 CTFd/themes/admin/assets/js/compat/graphs.js create mode 100644 CTFd/themes/admin/assets/js/compat/helpers.js create mode 100644 CTFd/themes/admin/assets/js/compat/json.js create mode 100644 CTFd/themes/admin/assets/js/compat/math.js create mode 100644 CTFd/themes/admin/assets/js/compat/patch.js create mode 100644 CTFd/themes/admin/assets/js/compat/styles.js create mode 100644 CTFd/themes/admin/assets/js/compat/times.js create mode 100644 CTFd/themes/admin/assets/js/compat/ui.js create mode 100644 CTFd/themes/admin/assets/js/compat/wc.js create mode 100644 CTFd/themes/admin/assets/js/components/comments/CommentBox.vue create mode 100644 CTFd/themes/admin/assets/js/components/configs/brackets/Bracket.vue create mode 100644 CTFd/themes/admin/assets/js/components/configs/brackets/BracketList.vue create mode 100644 CTFd/themes/admin/assets/js/components/configs/fields/Field.vue create mode 100644 CTFd/themes/admin/assets/js/components/configs/fields/FieldList.vue create mode 100644 CTFd/themes/admin/assets/js/components/files/ChallengeFilesList.vue create mode 100644 CTFd/themes/admin/assets/js/components/files/MediaLibrary.vue create mode 100644 CTFd/themes/admin/assets/js/components/flags/FlagCreationForm.vue create mode 100644 CTFd/themes/admin/assets/js/components/flags/FlagEditForm.vue create mode 100644 CTFd/themes/admin/assets/js/components/flags/FlagList.vue create mode 100644 CTFd/themes/admin/assets/js/components/hints/HintCreationForm.vue create mode 100644 CTFd/themes/admin/assets/js/components/hints/HintEditForm.vue create mode 100644 CTFd/themes/admin/assets/js/components/hints/HintsList.vue create mode 100644 CTFd/themes/admin/assets/js/components/next/NextChallenge.vue create mode 100644 CTFd/themes/admin/assets/js/components/notifications/Notification.vue create mode 100644 CTFd/themes/admin/assets/js/components/ratings/RatingsViewer.vue create mode 100644 CTFd/themes/admin/assets/js/components/requirements/Requirements.vue create mode 100644 CTFd/themes/admin/assets/js/components/solution/SolutionEditor.vue create mode 100644 CTFd/themes/admin/assets/js/components/tags/TagsList.vue create mode 100644 CTFd/themes/admin/assets/js/components/teams/UserAddForm.vue create mode 100644 CTFd/themes/admin/assets/js/components/topics/TopicsList.vue create mode 100644 CTFd/themes/admin/assets/js/pages/challenge.js create mode 100644 CTFd/themes/admin/assets/js/pages/challenges.js create mode 100644 CTFd/themes/admin/assets/js/pages/configs.js create mode 100644 CTFd/themes/admin/assets/js/pages/editor.js create mode 100644 CTFd/themes/admin/assets/js/pages/events.js create mode 100644 CTFd/themes/admin/assets/js/pages/main.js create mode 100644 CTFd/themes/admin/assets/js/pages/notifications.js create mode 100644 CTFd/themes/admin/assets/js/pages/pages.js create mode 100644 CTFd/themes/admin/assets/js/pages/reset.js create mode 100644 CTFd/themes/admin/assets/js/pages/scoreboard.js create mode 100644 CTFd/themes/admin/assets/js/pages/statistics.js create mode 100644 CTFd/themes/admin/assets/js/pages/style.js create mode 100644 CTFd/themes/admin/assets/js/pages/submissions.js create mode 100644 CTFd/themes/admin/assets/js/pages/team.js create mode 100644 CTFd/themes/admin/assets/js/pages/teams.js create mode 100644 CTFd/themes/admin/assets/js/pages/user.js create mode 100644 CTFd/themes/admin/assets/js/pages/users.js create mode 100644 CTFd/themes/admin/assets/js/styles.js create mode 100644 CTFd/themes/admin/assets/js/templates/admin-flags-table.njk create mode 100644 CTFd/themes/admin/assets/js/timezones.js create mode 100644 CTFd/themes/admin/package.json create mode 100644 CTFd/themes/admin/static/assets/CommentBox-CBClD8yz.js create mode 100644 CTFd/themes/admin/static/assets/CommentBox-CZ8OEr5k.css create mode 100644 CTFd/themes/admin/static/assets/admin-css-Bj5aeLSk.css create mode 100644 CTFd/themes/admin/static/assets/challenge-DY7T5JQl.css create mode 100644 CTFd/themes/admin/static/assets/challenge-board-css-CpAEjUPb.css create mode 100644 CTFd/themes/admin/static/assets/codemirror-css-DRHJUI8W.css create mode 100644 CTFd/themes/admin/static/assets/echarts-l0sNRNKZ.js create mode 100644 CTFd/themes/admin/static/assets/echarts.common-mykzcWB7.js create mode 100644 CTFd/themes/admin/static/assets/fonts-css-B0NUqHPX.css create mode 100644 CTFd/themes/admin/static/assets/graphs-jArcrQQ8.js create mode 100644 CTFd/themes/admin/static/assets/htmlmixed-vBdNL_dI.js create mode 100644 CTFd/themes/admin/static/assets/main-css-CoszOyzj.css create mode 100644 CTFd/themes/admin/static/assets/pages/challenge-BpP8h_jM.js create mode 100644 CTFd/themes/admin/static/assets/pages/challenges-D4GbEGKt.js create mode 100644 CTFd/themes/admin/static/assets/pages/configs-DdgMjjoH.js create mode 100644 CTFd/themes/admin/static/assets/pages/editor-Cjao4UkU.js create mode 100644 CTFd/themes/admin/static/assets/pages/main-CcyKUC_q.js create mode 100644 CTFd/themes/admin/static/assets/pages/notifications-wuA3N3gF.js create mode 100644 CTFd/themes/admin/static/assets/pages/pages-CHd53Cqu.js create mode 100644 CTFd/themes/admin/static/assets/pages/reset-B_I5EjEB.js create mode 100644 CTFd/themes/admin/static/assets/pages/scoreboard-DHe7f0Yh.js create mode 100644 CTFd/themes/admin/static/assets/pages/statistics-axPwqrbc.js create mode 100644 CTFd/themes/admin/static/assets/pages/submissions-DIux1aJH.js create mode 100644 CTFd/themes/admin/static/assets/pages/team-CiYqyHrZ.js create mode 100644 CTFd/themes/admin/static/assets/pages/teams-4c2lZGg6.js create mode 100644 CTFd/themes/admin/static/assets/pages/user-DtkurpXM.js create mode 100644 CTFd/themes/admin/static/assets/pages/users-CF1Z-5Al.js create mode 100644 CTFd/themes/admin/static/assets/tab-BrZ-GZoF.js create mode 100644 CTFd/themes/admin/static/img/README.md create mode 100644 CTFd/themes/admin/static/img/favicon.ico create mode 100644 CTFd/themes/admin/static/img/logo.png create mode 100644 CTFd/themes/admin/static/manifest.json create mode 100644 CTFd/themes/admin/static/sounds/README.md create mode 100644 CTFd/themes/admin/static/sounds/notification.mp3 create mode 100644 CTFd/themes/admin/static/sounds/notification.webm create mode 100644 CTFd/themes/admin/static/webfonts/fa-brands-400.ttf create mode 100644 CTFd/themes/admin/static/webfonts/fa-brands-400.woff2 create mode 100644 CTFd/themes/admin/static/webfonts/fa-regular-400.ttf create mode 100644 CTFd/themes/admin/static/webfonts/fa-regular-400.woff2 create mode 100644 CTFd/themes/admin/static/webfonts/fa-solid-900.ttf create mode 100644 CTFd/themes/admin/static/webfonts/fa-solid-900.woff2 create mode 100644 CTFd/themes/admin/static/webfonts/fa-v4compatibility.ttf create mode 100644 CTFd/themes/admin/static/webfonts/fa-v4compatibility.woff2 create mode 100644 CTFd/themes/admin/static/webfonts/lato-latin-400-normal.woff create mode 100644 CTFd/themes/admin/static/webfonts/lato-latin-400-normal.woff2 create mode 100644 CTFd/themes/admin/static/webfonts/lato-latin-700-normal.woff create mode 100644 CTFd/themes/admin/static/webfonts/lato-latin-700-normal.woff2 create mode 100644 CTFd/themes/admin/static/webfonts/lato-latin-ext-400-normal.woff create mode 100644 CTFd/themes/admin/static/webfonts/lato-latin-ext-400-normal.woff2 create mode 100644 CTFd/themes/admin/static/webfonts/lato-latin-ext-700-normal.woff create mode 100644 CTFd/themes/admin/static/webfonts/lato-latin-ext-700-normal.woff2 create mode 100644 CTFd/themes/admin/static/webfonts/raleway-cyrillic-500-normal.woff create mode 100644 CTFd/themes/admin/static/webfonts/raleway-cyrillic-500-normal.woff2 create mode 100644 CTFd/themes/admin/static/webfonts/raleway-cyrillic-ext-500-normal.woff create mode 100644 CTFd/themes/admin/static/webfonts/raleway-cyrillic-ext-500-normal.woff2 create mode 100644 CTFd/themes/admin/static/webfonts/raleway-latin-500-normal.woff create mode 100644 CTFd/themes/admin/static/webfonts/raleway-latin-500-normal.woff2 create mode 100644 CTFd/themes/admin/static/webfonts/raleway-latin-ext-500-normal.woff create mode 100644 CTFd/themes/admin/static/webfonts/raleway-latin-ext-500-normal.woff2 create mode 100644 CTFd/themes/admin/static/webfonts/raleway-vietnamese-500-normal.woff create mode 100644 CTFd/themes/admin/static/webfonts/raleway-vietnamese-500-normal.woff2 create mode 100644 CTFd/themes/admin/templates/base.html create mode 100644 CTFd/themes/admin/templates/challenges/challenge.html create mode 100644 CTFd/themes/admin/templates/challenges/challenges.html create mode 100644 CTFd/themes/admin/templates/challenges/create.html create mode 100644 CTFd/themes/admin/templates/challenges/new.html create mode 100644 CTFd/themes/admin/templates/challenges/preview.html create mode 100644 CTFd/themes/admin/templates/challenges/update.html create mode 100644 CTFd/themes/admin/templates/config.html create mode 100644 CTFd/themes/admin/templates/configs/accounts.html create mode 100644 CTFd/themes/admin/templates/configs/backup.html create mode 100644 CTFd/themes/admin/templates/configs/brackets.html create mode 100644 CTFd/themes/admin/templates/configs/challenges.html create mode 100644 CTFd/themes/admin/templates/configs/email.html create mode 100644 CTFd/themes/admin/templates/configs/fields.html create mode 100644 CTFd/themes/admin/templates/configs/general.html create mode 100644 CTFd/themes/admin/templates/configs/legal.html create mode 100644 CTFd/themes/admin/templates/configs/localization.html create mode 100644 CTFd/themes/admin/templates/configs/logo.html create mode 100644 CTFd/themes/admin/templates/configs/mlc.html create mode 100644 CTFd/themes/admin/templates/configs/pause.html create mode 100644 CTFd/themes/admin/templates/configs/registration_code.html create mode 100644 CTFd/themes/admin/templates/configs/robots.html create mode 100644 CTFd/themes/admin/templates/configs/sanitize.html create mode 100644 CTFd/themes/admin/templates/configs/social.html create mode 100644 CTFd/themes/admin/templates/configs/theme.html create mode 100644 CTFd/themes/admin/templates/configs/time.html create mode 100644 CTFd/themes/admin/templates/configs/usermode.html create mode 100644 CTFd/themes/admin/templates/configs/visibility.html create mode 100644 CTFd/themes/admin/templates/editor.html create mode 100644 CTFd/themes/admin/templates/import.html create mode 100644 CTFd/themes/admin/templates/integrations.html create mode 100644 CTFd/themes/admin/templates/macros/forms.html create mode 100644 CTFd/themes/admin/templates/modals/awards/create.html create mode 100644 CTFd/themes/admin/templates/modals/challenges/challenges.html create mode 100644 CTFd/themes/admin/templates/modals/challenges/files.html create mode 100644 CTFd/themes/admin/templates/modals/challenges/flags.html create mode 100644 CTFd/themes/admin/templates/modals/challenges/hints.html create mode 100644 CTFd/themes/admin/templates/modals/challenges/next.html create mode 100644 CTFd/themes/admin/templates/modals/challenges/requirements.html create mode 100644 CTFd/themes/admin/templates/modals/challenges/solution.html create mode 100644 CTFd/themes/admin/templates/modals/challenges/solves.html create mode 100644 CTFd/themes/admin/templates/modals/challenges/tags.html create mode 100644 CTFd/themes/admin/templates/modals/challenges/topics.html create mode 100644 CTFd/themes/admin/templates/modals/mail/send.html create mode 100644 CTFd/themes/admin/templates/modals/teams/addresses.html create mode 100644 CTFd/themes/admin/templates/modals/teams/captain.html create mode 100644 CTFd/themes/admin/templates/modals/teams/create.html create mode 100644 CTFd/themes/admin/templates/modals/teams/edit.html create mode 100644 CTFd/themes/admin/templates/modals/teams/statistics.html create mode 100644 CTFd/themes/admin/templates/modals/users/addresses.html create mode 100644 CTFd/themes/admin/templates/modals/users/create.html create mode 100644 CTFd/themes/admin/templates/modals/users/edit.html create mode 100644 CTFd/themes/admin/templates/modals/users/statistics.html create mode 100644 CTFd/themes/admin/templates/notifications.html create mode 100644 CTFd/themes/admin/templates/page.html create mode 100644 CTFd/themes/admin/templates/pages.html create mode 100644 CTFd/themes/admin/templates/reset.html create mode 100644 CTFd/themes/admin/templates/scoreboard.html create mode 100644 CTFd/themes/admin/templates/scoreboard/standings.html create mode 100644 CTFd/themes/admin/templates/scoreboard/users.html create mode 100644 CTFd/themes/admin/templates/statistics.html create mode 100644 CTFd/themes/admin/templates/submissions.html create mode 100644 CTFd/themes/admin/templates/teams/new.html create mode 100644 CTFd/themes/admin/templates/teams/team.html create mode 100644 CTFd/themes/admin/templates/teams/teams.html create mode 100644 CTFd/themes/admin/templates/users/new.html create mode 100644 CTFd/themes/admin/templates/users/user.html create mode 100644 CTFd/themes/admin/templates/users/users.html create mode 100644 CTFd/themes/admin/vite.config.js create mode 100644 CTFd/themes/admin/yarn.lock create mode 100644 CTFd/themes/core-deprecated/assets/css/challenge-board.scss create mode 100644 CTFd/themes/core-deprecated/assets/css/codemirror.scss create mode 100644 CTFd/themes/core-deprecated/assets/css/core.scss create mode 100644 CTFd/themes/core-deprecated/assets/css/fonts.scss create mode 100644 CTFd/themes/core-deprecated/assets/css/includes/award-icons.scss create mode 100644 CTFd/themes/core-deprecated/assets/css/includes/flag-icons.scss create mode 100644 CTFd/themes/core-deprecated/assets/css/includes/jumbotron.css create mode 100644 CTFd/themes/core-deprecated/assets/css/includes/sticky-footer.css create mode 100644 CTFd/themes/core-deprecated/assets/css/includes/utils/min-height.scss create mode 100644 CTFd/themes/core-deprecated/assets/css/includes/utils/opacity.scss create mode 100644 CTFd/themes/core-deprecated/assets/css/main.scss create mode 100644 CTFd/themes/core-deprecated/assets/js/CTFd.js create mode 100644 CTFd/themes/core-deprecated/assets/js/api.js create mode 100644 CTFd/themes/core-deprecated/assets/js/config.js create mode 100644 CTFd/themes/core-deprecated/assets/js/events.js create mode 100644 CTFd/themes/core-deprecated/assets/js/ezq.js create mode 100644 CTFd/themes/core-deprecated/assets/js/fetch.js create mode 100644 CTFd/themes/core-deprecated/assets/js/graphs.js create mode 100644 CTFd/themes/core-deprecated/assets/js/helpers.js create mode 100644 CTFd/themes/core-deprecated/assets/js/pages/challenges.js create mode 100644 CTFd/themes/core-deprecated/assets/js/pages/events.js create mode 100644 CTFd/themes/core-deprecated/assets/js/pages/main.js create mode 100644 CTFd/themes/core-deprecated/assets/js/pages/notifications.js create mode 100644 CTFd/themes/core-deprecated/assets/js/pages/scoreboard.js create mode 100644 CTFd/themes/core-deprecated/assets/js/pages/settings.js create mode 100644 CTFd/themes/core-deprecated/assets/js/pages/setup.js create mode 100644 CTFd/themes/core-deprecated/assets/js/pages/stats.js create mode 100644 CTFd/themes/core-deprecated/assets/js/pages/style.js create mode 100644 CTFd/themes/core-deprecated/assets/js/pages/teams/private.js create mode 100644 CTFd/themes/core-deprecated/assets/js/patch.js create mode 100644 CTFd/themes/core-deprecated/assets/js/styles.js create mode 100644 CTFd/themes/core-deprecated/assets/js/times.js create mode 100644 CTFd/themes/core-deprecated/assets/js/utils.js create mode 100644 CTFd/themes/core-deprecated/static/css/challenge-board.dev.css create mode 100644 CTFd/themes/core-deprecated/static/css/challenge-board.min.css create mode 100644 CTFd/themes/core-deprecated/static/css/codemirror.dev.css create mode 100644 CTFd/themes/core-deprecated/static/css/codemirror.min.css create mode 100644 CTFd/themes/core-deprecated/static/css/core.dev.css create mode 100644 CTFd/themes/core-deprecated/static/css/core.min.css create mode 100644 CTFd/themes/core-deprecated/static/css/fonts.dev.css create mode 100644 CTFd/themes/core-deprecated/static/css/fonts.min.css create mode 100644 CTFd/themes/core-deprecated/static/css/main.dev.css create mode 100644 CTFd/themes/core-deprecated/static/css/main.min.css create mode 100644 CTFd/themes/core-deprecated/static/fonts/fa-brands-400.eot create mode 100644 CTFd/themes/core-deprecated/static/fonts/fa-brands-400.svg create mode 100644 CTFd/themes/core-deprecated/static/fonts/fa-brands-400.ttf create mode 100644 CTFd/themes/core-deprecated/static/fonts/fa-brands-400.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/fa-brands-400.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/fa-regular-400.eot create mode 100644 CTFd/themes/core-deprecated/static/fonts/fa-regular-400.svg create mode 100644 CTFd/themes/core-deprecated/static/fonts/fa-regular-400.ttf create mode 100644 CTFd/themes/core-deprecated/static/fonts/fa-regular-400.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/fa-regular-400.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/fa-solid-900.eot create mode 100644 CTFd/themes/core-deprecated/static/fonts/fa-solid-900.svg create mode 100644 CTFd/themes/core-deprecated/static/fonts/fa-solid-900.ttf create mode 100644 CTFd/themes/core-deprecated/static/fonts/fa-solid-900.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/fa-solid-900.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/fontawesome-webfont.eot create mode 100644 CTFd/themes/core-deprecated/static/fonts/fontawesome-webfont.svg create mode 100644 CTFd/themes/core-deprecated/static/fonts/fontawesome-webfont.ttf create mode 100644 CTFd/themes/core-deprecated/static/fonts/fontawesome-webfont.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/fontawesome-webfont.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/lato-latin-100.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/lato-latin-100.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/lato-latin-100italic.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/lato-latin-100italic.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/lato-latin-300.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/lato-latin-300.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/lato-latin-300italic.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/lato-latin-300italic.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/lato-latin-400.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/lato-latin-400.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/lato-latin-400italic.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/lato-latin-400italic.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/lato-latin-700.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/lato-latin-700.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/lato-latin-700italic.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/lato-latin-700italic.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/lato-latin-900.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/lato-latin-900.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/lato-latin-900italic.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/lato-latin-900italic.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-100.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-100.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-100italic.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-100italic.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-200.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-200.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-200italic.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-200italic.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-300.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-300.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-300italic.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-300italic.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-400.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-400.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-400italic.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-400italic.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-500.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-500.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-500italic.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-500italic.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-600.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-600.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-600italic.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-600italic.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-700.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-700.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-700italic.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-700italic.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-800.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-800.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-800italic.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-800italic.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-900.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-900.woff2 create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-900italic.woff create mode 100644 CTFd/themes/core-deprecated/static/fonts/raleway-latin-900italic.woff2 create mode 100644 CTFd/themes/core-deprecated/static/img/ctfd.ai create mode 100644 CTFd/themes/core-deprecated/static/img/ctfd.svg create mode 100644 CTFd/themes/core-deprecated/static/img/ctfd_transfer.svg create mode 100644 CTFd/themes/core-deprecated/static/img/favicon.ico create mode 100644 CTFd/themes/core-deprecated/static/img/logo.png create mode 100644 CTFd/themes/core-deprecated/static/img/logo_old.png create mode 100644 CTFd/themes/core-deprecated/static/img/scoreboard.png create mode 100644 CTFd/themes/core-deprecated/static/js/core.dev.js create mode 100644 CTFd/themes/core-deprecated/static/js/core.min.js create mode 100644 CTFd/themes/core-deprecated/static/js/echarts.bundle.dev.js create mode 100644 CTFd/themes/core-deprecated/static/js/echarts.bundle.min.js create mode 100644 CTFd/themes/core-deprecated/static/js/helpers.dev.js create mode 100644 CTFd/themes/core-deprecated/static/js/helpers.min.js create mode 100644 CTFd/themes/core-deprecated/static/js/pages/challenges.dev.js create mode 100644 CTFd/themes/core-deprecated/static/js/pages/challenges.min.js create mode 100644 CTFd/themes/core-deprecated/static/js/pages/main.dev.js create mode 100644 CTFd/themes/core-deprecated/static/js/pages/main.min.js create mode 100644 CTFd/themes/core-deprecated/static/js/pages/notifications.dev.js create mode 100644 CTFd/themes/core-deprecated/static/js/pages/notifications.min.js create mode 100644 CTFd/themes/core-deprecated/static/js/pages/scoreboard.dev.js create mode 100644 CTFd/themes/core-deprecated/static/js/pages/scoreboard.min.js create mode 100644 CTFd/themes/core-deprecated/static/js/pages/settings.dev.js create mode 100644 CTFd/themes/core-deprecated/static/js/pages/settings.min.js create mode 100644 CTFd/themes/core-deprecated/static/js/pages/setup.dev.js create mode 100644 CTFd/themes/core-deprecated/static/js/pages/setup.min.js create mode 100644 CTFd/themes/core-deprecated/static/js/pages/stats.dev.js create mode 100644 CTFd/themes/core-deprecated/static/js/pages/stats.min.js create mode 100644 CTFd/themes/core-deprecated/static/js/pages/teams/private.dev.js create mode 100644 CTFd/themes/core-deprecated/static/js/pages/teams/private.min.js create mode 100644 CTFd/themes/core-deprecated/static/js/vendor.bundle.dev.js create mode 100644 CTFd/themes/core-deprecated/static/js/vendor.bundle.min.js create mode 100644 CTFd/themes/core-deprecated/static/sounds/README.md create mode 100644 CTFd/themes/core-deprecated/static/sounds/notification.mp3 create mode 100644 CTFd/themes/core-deprecated/static/sounds/notification.webm create mode 100644 CTFd/themes/core-deprecated/templates/base.html create mode 100644 CTFd/themes/core-deprecated/templates/challenge.html create mode 100644 CTFd/themes/core-deprecated/templates/challenges.html create mode 100644 CTFd/themes/core-deprecated/templates/components/errors.html create mode 100644 CTFd/themes/core-deprecated/templates/components/navbar.html create mode 100644 CTFd/themes/core-deprecated/templates/config.html create mode 100644 CTFd/themes/core-deprecated/templates/confirm.html create mode 100644 CTFd/themes/core-deprecated/templates/errors/403.html create mode 100644 CTFd/themes/core-deprecated/templates/errors/404.html create mode 100644 CTFd/themes/core-deprecated/templates/errors/429.html create mode 100644 CTFd/themes/core-deprecated/templates/errors/500.html create mode 100644 CTFd/themes/core-deprecated/templates/errors/502.html create mode 100644 CTFd/themes/core-deprecated/templates/login.html create mode 100644 CTFd/themes/core-deprecated/templates/macros/forms.html create mode 100644 CTFd/themes/core-deprecated/templates/notifications.html create mode 100644 CTFd/themes/core-deprecated/templates/page.html create mode 100644 CTFd/themes/core-deprecated/templates/register.html create mode 100644 CTFd/themes/core-deprecated/templates/reset_password.html create mode 100644 CTFd/themes/core-deprecated/templates/scoreboard.html create mode 100644 CTFd/themes/core-deprecated/templates/settings.html create mode 100644 CTFd/themes/core-deprecated/templates/setup.html create mode 100644 CTFd/themes/core-deprecated/templates/teams/invite.html create mode 100644 CTFd/themes/core-deprecated/templates/teams/join_team.html create mode 100644 CTFd/themes/core-deprecated/templates/teams/new_team.html create mode 100644 CTFd/themes/core-deprecated/templates/teams/private.html create mode 100644 CTFd/themes/core-deprecated/templates/teams/public.html create mode 100644 CTFd/themes/core-deprecated/templates/teams/team_enrollment.html create mode 100644 CTFd/themes/core-deprecated/templates/teams/teams.html create mode 100644 CTFd/themes/core-deprecated/templates/users/private.html create mode 100644 CTFd/themes/core-deprecated/templates/users/public.html create mode 100644 CTFd/themes/core-deprecated/templates/users/users.html create mode 100644 CTFd/themes/core/.github/workflows/build.yml create mode 100644 CTFd/themes/core/.github/workflows/prettier.yml create mode 100644 CTFd/themes/core/.gitignore create mode 100644 CTFd/themes/core/.prettierignore create mode 100644 CTFd/themes/core/.prettierrc.json create mode 100644 CTFd/themes/core/LICENSE create mode 100644 CTFd/themes/core/README.md create mode 100644 CTFd/themes/core/assets/img/ctfd.ai create mode 100644 CTFd/themes/core/assets/img/ctfd.svg create mode 100644 CTFd/themes/core/assets/img/ctfd_transfer.svg create mode 100644 CTFd/themes/core/assets/img/favicon.ico create mode 100644 CTFd/themes/core/assets/img/logo.png create mode 100644 CTFd/themes/core/assets/img/logo_old.png create mode 100644 CTFd/themes/core/assets/img/scoreboard.png create mode 100644 CTFd/themes/core/assets/js/challenges.js create mode 100644 CTFd/themes/core/assets/js/color_mode_switcher.js create mode 100644 CTFd/themes/core/assets/js/components/language.js create mode 100644 CTFd/themes/core/assets/js/index.js create mode 100644 CTFd/themes/core/assets/js/notifications.js create mode 100644 CTFd/themes/core/assets/js/page.js create mode 100644 CTFd/themes/core/assets/js/scoreboard.js create mode 100644 CTFd/themes/core/assets/js/settings.js create mode 100644 CTFd/themes/core/assets/js/setup.js create mode 100644 CTFd/themes/core/assets/js/teams/list.js create mode 100644 CTFd/themes/core/assets/js/teams/private.js create mode 100644 CTFd/themes/core/assets/js/teams/public.js create mode 100644 CTFd/themes/core/assets/js/theme/highlight.js create mode 100644 CTFd/themes/core/assets/js/theme/styles.js create mode 100644 CTFd/themes/core/assets/js/theme/times.js create mode 100644 CTFd/themes/core/assets/js/users/list.js create mode 100644 CTFd/themes/core/assets/js/users/private.js create mode 100644 CTFd/themes/core/assets/js/users/public.js create mode 100644 CTFd/themes/core/assets/js/utils/alerts.js create mode 100644 CTFd/themes/core/assets/js/utils/clipboard.js create mode 100644 CTFd/themes/core/assets/js/utils/collapse.js create mode 100644 CTFd/themes/core/assets/js/utils/graphs/echarts/categories.js create mode 100644 CTFd/themes/core/assets/js/utils/graphs/echarts/index.js create mode 100644 CTFd/themes/core/assets/js/utils/graphs/echarts/scoreboard.js create mode 100644 CTFd/themes/core/assets/js/utils/graphs/echarts/solve-percentage.js create mode 100644 CTFd/themes/core/assets/js/utils/graphs/echarts/userscore.js create mode 100644 CTFd/themes/core/assets/js/utils/graphs/vega/categories.js create mode 100644 CTFd/themes/core/assets/js/utils/graphs/vega/scoreboard.js create mode 100644 CTFd/themes/core/assets/js/utils/graphs/vega/solve-percentage.js create mode 100644 CTFd/themes/core/assets/js/utils/graphs/vega/userscore.js create mode 100644 CTFd/themes/core/assets/js/utils/math.js create mode 100644 CTFd/themes/core/assets/js/utils/notifications/alerts.js create mode 100644 CTFd/themes/core/assets/js/utils/notifications/read.js create mode 100644 CTFd/themes/core/assets/js/utils/notifications/toasts.js create mode 100644 CTFd/themes/core/assets/js/utils/objects.js create mode 100644 CTFd/themes/core/assets/js/utils/tooltips.js create mode 100644 CTFd/themes/core/assets/scss/includes/components/_challenge.scss create mode 100644 CTFd/themes/core/assets/scss/includes/components/_graphs.scss create mode 100644 CTFd/themes/core/assets/scss/includes/components/_jumbotron.scss create mode 100644 CTFd/themes/core/assets/scss/includes/components/_sticky-footer.scss create mode 100644 CTFd/themes/core/assets/scss/includes/components/_table.scss create mode 100644 CTFd/themes/core/assets/scss/includes/icons/_award-icons.scss create mode 100644 CTFd/themes/core/assets/scss/includes/icons/_flag-icons.scss create mode 100644 CTFd/themes/core/assets/scss/includes/utils/_cursors.scss create mode 100644 CTFd/themes/core/assets/scss/includes/utils/_fonts.scss create mode 100644 CTFd/themes/core/assets/scss/includes/utils/_lolight.scss create mode 100644 CTFd/themes/core/assets/scss/includes/utils/_min-height.scss create mode 100644 CTFd/themes/core/assets/scss/includes/utils/_opacity.scss create mode 100644 CTFd/themes/core/assets/scss/main.scss create mode 100644 CTFd/themes/core/assets/sounds/README.md create mode 100644 CTFd/themes/core/assets/sounds/notification.mp3 create mode 100644 CTFd/themes/core/assets/sounds/notification.webm create mode 100644 CTFd/themes/core/package.json create mode 100644 CTFd/themes/core/postcss.config.js create mode 100644 CTFd/themes/core/static/assets/challenges.6ea6caae.js create mode 100644 CTFd/themes/core/static/assets/clipboard.b2a3c241.js create mode 100644 CTFd/themes/core/static/assets/color_mode_switcher.52334129.js create mode 100644 CTFd/themes/core/static/assets/echarts.128204f2.js create mode 100644 CTFd/themes/core/static/assets/index.1cf73b05.js create mode 100644 CTFd/themes/core/static/assets/index.9b9a8697.js create mode 100644 CTFd/themes/core/static/assets/main.e9ec7884.css create mode 100644 CTFd/themes/core/static/assets/notifications.da39e2fc.js create mode 100644 CTFd/themes/core/static/assets/page.94550e0c.js create mode 100644 CTFd/themes/core/static/assets/scoreboard.024f37bd.js create mode 100644 CTFd/themes/core/static/assets/settings.13fb7a9e.js create mode 100644 CTFd/themes/core/static/assets/setup.8ef4208b.js create mode 100644 CTFd/themes/core/static/assets/teams_list.3872f430.js create mode 100644 CTFd/themes/core/static/assets/teams_private.ccdfcaf5.js create mode 100644 CTFd/themes/core/static/assets/teams_public.866f8286.js create mode 100644 CTFd/themes/core/static/assets/users_list.35657373.js create mode 100644 CTFd/themes/core/static/assets/users_private.d43c8fa2.js create mode 100644 CTFd/themes/core/static/assets/users_public.9b4bfe95.js create mode 100644 CTFd/themes/core/static/assets/userscore.fadbe0bc.js create mode 100644 CTFd/themes/core/static/img/ctfd.ai create mode 100644 CTFd/themes/core/static/img/ctfd.svg create mode 100644 CTFd/themes/core/static/img/ctfd_transfer.svg create mode 100644 CTFd/themes/core/static/img/favicon.ico create mode 100644 CTFd/themes/core/static/img/logo.png create mode 100644 CTFd/themes/core/static/img/logo_old.png create mode 100644 CTFd/themes/core/static/img/scoreboard.png create mode 100644 CTFd/themes/core/static/manifest.json create mode 100644 CTFd/themes/core/static/sounds/README.md create mode 100644 CTFd/themes/core/static/sounds/notification.mp3 create mode 100644 CTFd/themes/core/static/sounds/notification.webm create mode 100644 CTFd/themes/core/static/webfonts/fa-brands-400.ttf create mode 100644 CTFd/themes/core/static/webfonts/fa-brands-400.woff2 create mode 100644 CTFd/themes/core/static/webfonts/fa-regular-400.ttf create mode 100644 CTFd/themes/core/static/webfonts/fa-regular-400.woff2 create mode 100644 CTFd/themes/core/static/webfonts/fa-solid-900.ttf create mode 100644 CTFd/themes/core/static/webfonts/fa-solid-900.woff2 create mode 100644 CTFd/themes/core/static/webfonts/fa-v4compatibility.ttf create mode 100644 CTFd/themes/core/static/webfonts/fa-v4compatibility.woff2 create mode 100644 CTFd/themes/core/static/webfonts/lato-all-400-normal.woff create mode 100644 CTFd/themes/core/static/webfonts/lato-all-700-normal.woff create mode 100644 CTFd/themes/core/static/webfonts/lato-latin-400-normal.woff create mode 100644 CTFd/themes/core/static/webfonts/lato-latin-400-normal.woff2 create mode 100644 CTFd/themes/core/static/webfonts/lato-latin-700-normal.woff create mode 100644 CTFd/themes/core/static/webfonts/lato-latin-700-normal.woff2 create mode 100644 CTFd/themes/core/static/webfonts/lato-latin-ext-400-normal.woff create mode 100644 CTFd/themes/core/static/webfonts/lato-latin-ext-400-normal.woff2 create mode 100644 CTFd/themes/core/static/webfonts/lato-latin-ext-700-normal.woff create mode 100644 CTFd/themes/core/static/webfonts/lato-latin-ext-700-normal.woff2 create mode 100644 CTFd/themes/core/static/webfonts/raleway-all-400-normal.woff create mode 100644 CTFd/themes/core/static/webfonts/raleway-cyrillic-400-normal.woff create mode 100644 CTFd/themes/core/static/webfonts/raleway-cyrillic-400-normal.woff2 create mode 100644 CTFd/themes/core/static/webfonts/raleway-cyrillic-ext-400-normal.woff create mode 100644 CTFd/themes/core/static/webfonts/raleway-cyrillic-ext-400-normal.woff2 create mode 100644 CTFd/themes/core/static/webfonts/raleway-latin-400-normal.woff create mode 100644 CTFd/themes/core/static/webfonts/raleway-latin-400-normal.woff2 create mode 100644 CTFd/themes/core/static/webfonts/raleway-latin-ext-400-normal.woff create mode 100644 CTFd/themes/core/static/webfonts/raleway-latin-ext-400-normal.woff2 create mode 100644 CTFd/themes/core/static/webfonts/raleway-vietnamese-400-normal.woff create mode 100644 CTFd/themes/core/static/webfonts/raleway-vietnamese-400-normal.woff2 create mode 100644 CTFd/themes/core/templates/base.html create mode 100644 CTFd/themes/core/templates/challenge.html create mode 100644 CTFd/themes/core/templates/challenges.html create mode 100644 CTFd/themes/core/templates/components/errors.html create mode 100644 CTFd/themes/core/templates/components/navbar.html create mode 100644 CTFd/themes/core/templates/components/notifications.html create mode 100644 CTFd/themes/core/templates/components/snackbar.html create mode 100644 CTFd/themes/core/templates/config.html create mode 100644 CTFd/themes/core/templates/confirm.html create mode 100644 CTFd/themes/core/templates/errors/403.html create mode 100644 CTFd/themes/core/templates/errors/404.html create mode 100644 CTFd/themes/core/templates/errors/429.html create mode 100644 CTFd/themes/core/templates/errors/500.html create mode 100644 CTFd/themes/core/templates/errors/502.html create mode 100644 CTFd/themes/core/templates/login.html create mode 100644 CTFd/themes/core/templates/macros/forms.html create mode 100644 CTFd/themes/core/templates/notifications.html create mode 100644 CTFd/themes/core/templates/page.html create mode 100644 CTFd/themes/core/templates/register.html create mode 100644 CTFd/themes/core/templates/reset_password.html create mode 100644 CTFd/themes/core/templates/scoreboard.html create mode 100644 CTFd/themes/core/templates/settings.html create mode 100644 CTFd/themes/core/templates/setup.html create mode 100644 CTFd/themes/core/templates/teams/invite.html create mode 100644 CTFd/themes/core/templates/teams/join_team.html create mode 100644 CTFd/themes/core/templates/teams/new_team.html create mode 100644 CTFd/themes/core/templates/teams/private.html create mode 100644 CTFd/themes/core/templates/teams/public.html create mode 100644 CTFd/themes/core/templates/teams/team_enrollment.html create mode 100644 CTFd/themes/core/templates/teams/teams.html create mode 100644 CTFd/themes/core/templates/users/private.html create mode 100644 CTFd/themes/core/templates/users/public.html create mode 100644 CTFd/themes/core/templates/users/users.html create mode 100644 CTFd/themes/core/vite.config.js create mode 100644 CTFd/themes/core/yarn.lock create mode 100644 CTFd/translations/ar/LC_MESSAGES/messages.mo create mode 100644 CTFd/translations/ar/LC_MESSAGES/messages.po create mode 100644 CTFd/translations/bg/LC_MESSAGES/messages.mo create mode 100644 CTFd/translations/bg/LC_MESSAGES/messages.po create mode 100644 CTFd/translations/ca/LC_MESSAGES/messages.mo create mode 100644 CTFd/translations/ca/LC_MESSAGES/messages.po create mode 100644 CTFd/translations/cs/LC_MESSAGES/messages.mo create mode 100644 CTFd/translations/cs/LC_MESSAGES/messages.po create mode 100644 CTFd/translations/de/LC_MESSAGES/messages.mo create mode 100644 CTFd/translations/de/LC_MESSAGES/messages.po create mode 100644 CTFd/translations/el/LC_MESSAGES/messages.mo create mode 100644 CTFd/translations/el/LC_MESSAGES/messages.po create mode 100644 CTFd/translations/es/LC_MESSAGES/messages.mo create mode 100644 CTFd/translations/es/LC_MESSAGES/messages.po create mode 100644 CTFd/translations/fi/LC_MESSAGES/messages.mo create mode 100644 CTFd/translations/fi/LC_MESSAGES/messages.po create mode 100644 CTFd/translations/fr/LC_MESSAGES/messages.mo create mode 100644 CTFd/translations/fr/LC_MESSAGES/messages.po create mode 100644 CTFd/translations/he/LC_MESSAGES/messages.mo create mode 100644 CTFd/translations/he/LC_MESSAGES/messages.po create mode 100644 CTFd/translations/it/LC_MESSAGES/messages.mo create mode 100644 CTFd/translations/it/LC_MESSAGES/messages.po create mode 100644 CTFd/translations/ja/LC_MESSAGES/messages.mo create mode 100644 CTFd/translations/ja/LC_MESSAGES/messages.po create mode 100644 CTFd/translations/ko/LC_MESSAGES/messages.mo create mode 100644 CTFd/translations/ko/LC_MESSAGES/messages.po create mode 100644 CTFd/translations/lt/LC_MESSAGES/messages.mo create mode 100644 CTFd/translations/lt/LC_MESSAGES/messages.po create mode 100644 CTFd/translations/pl/LC_MESSAGES/messages.mo create mode 100644 CTFd/translations/pl/LC_MESSAGES/messages.po create mode 100644 CTFd/translations/pt_BR/LC_MESSAGES/messages.mo create mode 100644 CTFd/translations/pt_BR/LC_MESSAGES/messages.po create mode 100644 CTFd/translations/ro/LC_MESSAGES/messages.mo create mode 100644 CTFd/translations/ro/LC_MESSAGES/messages.po create mode 100644 CTFd/translations/ru/LC_MESSAGES/messages.mo create mode 100644 CTFd/translations/ru/LC_MESSAGES/messages.po create mode 100644 CTFd/translations/sk/LC_MESSAGES/messages.mo create mode 100644 CTFd/translations/sk/LC_MESSAGES/messages.po create mode 100644 CTFd/translations/sl/LC_MESSAGES/messages.mo create mode 100644 CTFd/translations/sl/LC_MESSAGES/messages.po create mode 100644 CTFd/translations/sv/LC_MESSAGES/messages.mo create mode 100644 CTFd/translations/sv/LC_MESSAGES/messages.po create mode 100644 CTFd/translations/uz/LC_MESSAGES/messages.mo create mode 100644 CTFd/translations/uz/LC_MESSAGES/messages.po create mode 100644 CTFd/translations/vi/LC_MESSAGES/messages.mo create mode 100644 CTFd/translations/vi/LC_MESSAGES/messages.po create mode 100644 CTFd/translations/zh_Hans_CN/LC_MESSAGES/messages.mo create mode 100644 CTFd/translations/zh_Hans_CN/LC_MESSAGES/messages.po create mode 100644 CTFd/translations/zh_Hant_TW/LC_MESSAGES/messages.mo create mode 100644 CTFd/translations/zh_Hant_TW/LC_MESSAGES/messages.po create mode 100644 CTFd/users.py create mode 100644 CTFd/utils/__init__.py create mode 100644 CTFd/utils/challenges/__init__.py create mode 100644 CTFd/utils/config/__init__.py create mode 100644 CTFd/utils/config/integrations.py create mode 100644 CTFd/utils/config/pages.py create mode 100644 CTFd/utils/config/visibility.py create mode 100644 CTFd/utils/countries/__init__.py create mode 100644 CTFd/utils/countries/geoip.py create mode 100644 CTFd/utils/crypto/__init__.py create mode 100644 CTFd/utils/csv/__init__.py create mode 100644 CTFd/utils/dates/__init__.py create mode 100644 CTFd/utils/decorators/__init__.py create mode 100644 CTFd/utils/decorators/modes.py create mode 100644 CTFd/utils/decorators/visibility.py create mode 100644 CTFd/utils/email/__init__.py create mode 100644 CTFd/utils/email/mailgun.py 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 create mode 100644 CTFd/utils/email/smtp.py create mode 100644 CTFd/utils/encoding/__init__.py create mode 100644 CTFd/utils/events/__init__.py create mode 100644 CTFd/utils/exports/__init__.py create mode 100644 CTFd/utils/exports/databases.py create mode 100644 CTFd/utils/exports/freeze.py create mode 100644 CTFd/utils/exports/serializers.py create mode 100644 CTFd/utils/formatters/__init__.py create mode 100644 CTFd/utils/health/__init__.py create mode 100644 CTFd/utils/helpers/__init__.py create mode 100644 CTFd/utils/helpers/models.py create mode 100644 CTFd/utils/humanize/__init__.py create mode 100644 CTFd/utils/humanize/numbers.py create mode 100644 CTFd/utils/humanize/words.py create mode 100644 CTFd/utils/initialization/__init__.py create mode 100644 CTFd/utils/logging/__init__.py create mode 100644 CTFd/utils/migrations/__init__.py create mode 100644 CTFd/utils/modes/__init__.py create mode 100644 CTFd/utils/notifications/__init__.py create mode 100644 CTFd/utils/plugins/__init__.py create mode 100644 CTFd/utils/scoreboard/__init__.py create mode 100644 CTFd/utils/scores/__init__.py create mode 100644 CTFd/utils/security/__init__.py create mode 100644 CTFd/utils/security/auth.py create mode 100644 CTFd/utils/security/csrf.py create mode 100644 CTFd/utils/security/email.py create mode 100644 CTFd/utils/security/passwords.py create mode 100644 CTFd/utils/security/sanitize.py create mode 100644 CTFd/utils/security/signing.py create mode 100644 CTFd/utils/sessions/__init__.py create mode 100644 CTFd/utils/social/__init__.py create mode 100644 CTFd/utils/updates/__init__.py create mode 100644 CTFd/utils/uploads/__init__.py create mode 100644 CTFd/utils/uploads/uploaders.py create mode 100644 CTFd/utils/user/__init__.py create mode 100644 CTFd/utils/validators/__init__.py create mode 100644 CTFd/views.py create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 Vagrantfile create mode 100644 babel.cfg create mode 100644 conf/nginx/http.conf create mode 100644 crowdin.yml create mode 100644 development.txt create mode 100644 docker-compose.yml create mode 100644 docker-entrypoint.sh create mode 100644 export.py create mode 100644 import.py create mode 100644 linting.txt create mode 100644 manage.py create mode 100644 messages.pot create mode 100644 migrations/1_2_0_upgrade_2_0_0.py create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/0366ba6575ca_add_table_for_comments.py create mode 100644 migrations/versions/07dfbe5e1edc_add_format_to_pages.py create mode 100644 migrations/versions/080d29b15cd3_add_tokens_table.py create mode 100644 migrations/versions/0def790057c1_add_language_column_to_users_table.py create mode 100644 migrations/versions/1093835a1051_add_default_email_templates.py create mode 100644 migrations/versions/24ad6790bc3c_convert_rating_values_to_votes.py create mode 100644 migrations/versions/364b4efa1686_add_ratings_table.py create mode 100644 migrations/versions/46a278193a94_enable_millisecond_precision_in_mysql_.py create mode 100644 migrations/versions/4d3c1b59d011_add_next_id_to_challenges_table.py create mode 100644 migrations/versions/4e4d5a9ea000_add_type_to_awards.py create mode 100644 migrations/versions/4fe3eeed9a9d_add_attribution_to_challenges.py create mode 100644 migrations/versions/55623b100da8_add_target_column_to_tracking.py create mode 100644 migrations/versions/5c4996aeb2cb_add_sha1sum_field_to_files_require_.py create mode 100644 migrations/versions/5c98d9253f56_rename_core_beta_to_core.py create mode 100644 migrations/versions/6012fe8de495_add_connection_info_column_to_challenges.py create mode 100644 migrations/versions/62bf576b2cd3_add_solutions_table.py create mode 100644 migrations/versions/662d728ad7da_add_change_password_to_users.py create mode 100644 migrations/versions/67ebab6de598_add_dynamic_scoring_columns_to_.py create mode 100644 migrations/versions/75e8ab9a0014_add_fields_and_fieldentries_tables.py create mode 100644 migrations/versions/8369118943a1_initial_revision.py create mode 100644 migrations/versions/9889b8c53673_add_brackets_table.py create mode 100644 migrations/versions/9e6f6578ca84_add_description_column_to_tokens_table.py create mode 100644 migrations/versions/a02c5bf43407_add_link_target_to_pages.py create mode 100644 migrations/versions/a03403986a32_add_theme_code_injections_to_configs.py create mode 100644 migrations/versions/a49ad66aa0f1_add_title_to_hint.py create mode 100644 migrations/versions/b295b033364d_add_ondelete_cascade_to_foreign_keys.py create mode 100644 migrations/versions/b5551cd26764_add_captain_column_to_teams.py create mode 100644 migrations/versions/ef87d69ec29a_add_topics_and_challenge_topics_tables.py create mode 100644 migrations/versions/f73a96c97449_add_logic_column_to_challenges.py create mode 100644 ping.py create mode 100644 populate.py create mode 100644 prepare.sh create mode 100644 requirements.in create mode 100644 requirements.txt create mode 100644 scripts/install_docker.sh create mode 100644 scripts/pip-compile.sh create mode 100644 serve.py create mode 100644 setup.cfg create mode 100644 tests/__init__.py create mode 100644 tests/admin/__init__.py create mode 100644 tests/admin/test_challenges.py create mode 100644 tests/admin/test_config.py create mode 100644 tests/admin/test_csv.py create mode 100644 tests/admin/test_fields.py create mode 100644 tests/admin/test_notifications.py create mode 100644 tests/admin/test_pages.py create mode 100644 tests/admin/test_scoreboard.py create mode 100644 tests/admin/test_statistics.py create mode 100644 tests/admin/test_submissions.py create mode 100644 tests/admin/test_teams.py create mode 100644 tests/admin/test_users.py create mode 100644 tests/admin/test_views.py create mode 100644 tests/api/__init__.py create mode 100644 tests/api/test_tokens.py create mode 100644 tests/api/v1/__init__.py create mode 100644 tests/api/v1/challenges/requirements/test_requirements.py create mode 100644 tests/api/v1/statistics/__init__.py create mode 100644 tests/api/v1/statistics/test_scores.py create mode 100644 tests/api/v1/teams/__init__.py create mode 100644 tests/api/v1/teams/test_scoring.py create mode 100644 tests/api/v1/teams/test_team_members.py create mode 100644 tests/api/v1/teams/test_teams.py create mode 100644 tests/api/v1/test_awards.py create mode 100644 tests/api/v1/test_brackets.py create mode 100644 tests/api/v1/test_challenges.py create mode 100644 tests/api/v1/test_comments.py create mode 100644 tests/api/v1/test_config.py create mode 100644 tests/api/v1/test_csrf.py create mode 100644 tests/api/v1/test_exports.py create mode 100644 tests/api/v1/test_fields.py create mode 100644 tests/api/v1/test_files.py create mode 100644 tests/api/v1/test_flags.py create mode 100644 tests/api/v1/test_hints.py create mode 100644 tests/api/v1/test_notifications.py create mode 100644 tests/api/v1/test_pages.py create mode 100644 tests/api/v1/test_scoreboard.py create mode 100644 tests/api/v1/test_solutions.py create mode 100644 tests/api/v1/test_submissions.py create mode 100644 tests/api/v1/test_tags.py create mode 100644 tests/api/v1/test_teams.py create mode 100644 tests/api/v1/test_tokens.py create mode 100644 tests/api/v1/test_topics.py create mode 100644 tests/api/v1/test_users.py create mode 100644 tests/api/v1/user/__init__.py create mode 100644 tests/api/v1/user/test_admin_access.py create mode 100644 tests/api/v1/user/test_challenges.py create mode 100644 tests/api/v1/user/test_hints.py create mode 100644 tests/api/v1/users/__init__.py create mode 100644 tests/api/v1/users/test_scoring.py create mode 100644 tests/api/v1/users/test_users.py create mode 100644 tests/brackets/test_brackets.py create mode 100644 tests/cache/__init__.py create mode 100644 tests/cache/test_cache.py create mode 100644 tests/cache/test_challenges.py create mode 100644 tests/challenges/__init__.py create mode 100644 tests/challenges/test_base_challenge.py create mode 100644 tests/challenges/test_challenge_logic.py create mode 100644 tests/challenges/test_challenge_types.py create mode 100644 tests/challenges/test_dynamic.py create mode 100644 tests/challenges/test_ratings.py create mode 100644 tests/challenges/test_standard_dynamic.py create mode 100644 tests/constants/test_constants.py create mode 100644 tests/constants/time.py create mode 100644 tests/helpers.py create mode 100644 tests/models/test_model_utils.py create mode 100644 tests/oauth/__init__.py create mode 100644 tests/oauth/test_redirect.py create mode 100644 tests/oauth/test_teams.py create mode 100644 tests/oauth/test_users.py create mode 100644 tests/teams/__init__.py create mode 100644 tests/teams/test_auth.py create mode 100644 tests/teams/test_challenges.py create mode 100644 tests/teams/test_fields.py create mode 100644 tests/teams/test_hidden_team_scores.py create mode 100644 tests/teams/test_hints.py create mode 100644 tests/teams/test_invites.py create mode 100644 tests/teams/test_scoreboard.py create mode 100644 tests/teams/test_teams.py create mode 100644 tests/test_config.py create mode 100644 tests/test_legal.py create mode 100644 tests/test_plugin_utils.py create mode 100644 tests/test_setup.py create mode 100644 tests/test_share.py create mode 100644 tests/test_themes.py create mode 100644 tests/test_views.py create mode 100644 tests/users/__init__.py create mode 100644 tests/users/test_auth.py create mode 100644 tests/users/test_challenges.py create mode 100644 tests/users/test_fields.py create mode 100644 tests/users/test_hints.py create mode 100644 tests/users/test_profile.py create mode 100644 tests/users/test_scoreboard.py create mode 100644 tests/users/test_settings.py create mode 100644 tests/users/test_setup.py create mode 100644 tests/users/test_submissions.py create mode 100644 tests/users/test_users.py create mode 100644 tests/utils/__init__.py create mode 100644 tests/utils/test_ctftime.py create mode 100644 tests/utils/test_email.py create mode 100644 tests/utils/test_encoding.py create mode 100644 tests/utils/test_events.py create mode 100644 tests/utils/test_exports.py create mode 100644 tests/utils/test_formatters.py create mode 100644 tests/utils/test_humanize.py create mode 100644 tests/utils/test_markdown.py create mode 100644 tests/utils/test_passwords.py create mode 100644 tests/utils/test_plugins.py create mode 100644 tests/utils/test_ratelimit.py create mode 100644 tests/utils/test_sanitize.py create mode 100644 tests/utils/test_sessions.py create mode 100644 tests/utils/test_updates.py create mode 100644 tests/utils/test_uploaders.py create mode 100644 tests/utils/test_validators.py create mode 100644 wsgi.py diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..35dff8b --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,9 @@ +coverage: + status: + project: + default: + # Fail the status if coverage drops by >= 1% + threshold: 1 + patch: + default: + threshold: 1 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7937a73 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +CTFd/logs/*.log +CTFd/static/uploads +CTFd/uploads +CTFd/*.db +CTFd/uploads/**/* +.ctfd_secret_key +.data +.git +.codecov.yml +.dockerignore +.github +.gitignore +.prettierignore +.travis.yml +**/node_modules +**/*.pyc +**/__pycache__ +.venv* +venv* \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..d389b9f --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,18 @@ +module.exports = { + "env": { + "browser": true, + "es6": true + }, + "extends": "eslint:recommended", + "globals": { + "Atomics": "readonly", + "SharedArrayBuffer": "readonly" + }, + "parserOptions": { + "ecmaVersion": 2018, + "sourceType": "module" + }, + "rules": { + "no-unused-vars": ["error", { "argsIgnorePattern": "^_" }] + } +}; \ No newline at end of file diff --git a/.flaskenv b/.flaskenv new file mode 100644 index 0000000..d41e74c --- /dev/null +++ b/.flaskenv @@ -0,0 +1,2 @@ +FLASK_ENV=development +FLASK_RUN_PORT=4000 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..d995728 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: CTFd diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..de174fc --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,19 @@ + + +**Environment**: + +- CTFd Version/Commit: +- Operating System: +- Web Browser and Version: + +**What happened?** + +**What did you expect to happen?** + +**How to reproduce your issue** + +**Any associated stack traces or error logs** diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000..fdde0ea --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,46 @@ +name: Docker build image on release + +on: + release: + types: [published] + +jobs: + docker: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - 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@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v3 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ 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 }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..a0d866c --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,47 @@ +--- +name: Linting + +on: [push, pull_request] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ['3.11'] + + name: Linting + steps: + - uses: actions/checkout@v2 + - name: Setup python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r linting.txt + sudo yarn --cwd CTFd/themes/admin install --non-interactive + sudo yarn global add prettier@3.2.5 + + - name: Lint + run: make lint + env: + TESTING_DATABASE_URL: 'sqlite://' + + - name: Lint Dockerfile + uses: brpaz/hadolint-action@master + with: + dockerfile: "Dockerfile" + + - name: Lint docker-compose + run: | + docker compose -f docker-compose.yml config + + - name: Lint translations + run: | + make translations-lint + diff --git a/.github/workflows/mariadb.yml b/.github/workflows/mariadb.yml new file mode 100644 index 0000000..21cdac8 --- /dev/null +++ b/.github/workflows/mariadb.yml @@ -0,0 +1,59 @@ +--- +name: CTFd MariaDB CI + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + build: + + runs-on: ubuntu-latest + services: + mariadb: + image: mariadb:10.11 + env: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: ctfd + ports: + - 3306 + redis: + image: redis + ports: + - 6379:6379 + + strategy: + matrix: + python-version: ['3.11'] + + name: Python ${{ matrix.python-version }} + steps: + - uses: actions/checkout@v2 + - name: Setup python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r development.txt + sudo yarn install --non-interactive + + - name: Test + run: | + sudo rm -f /etc/boto.cfg + make test + env: + AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE + AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + TESTING_DATABASE_URL: mysql+pymysql://root:password@localhost:${{ job.services.mariadb.ports[3306] }}/ctfd + + - name: Codecov + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml diff --git a/.github/workflows/mirror-core-theme.yml b/.github/workflows/mirror-core-theme.yml new file mode 100644 index 0000000..9ae5a25 --- /dev/null +++ b/.github/workflows/mirror-core-theme.yml @@ -0,0 +1,30 @@ +name: Mirror core-theme +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + mirror: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # need full history for subtree + + - name: Setup SSH for deploy key + run: | + mkdir -p ~/.ssh + echo "${{ secrets.CORE_THEME_DEPLOY_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan github.com >> ~/.ssh/known_hosts + + - name: Setup git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "username@users.noreply.github.com" + + - name: Push subtree + run: | + git subtree push --prefix="CTFd/themes/core" "git@github.com:CTFd/core-theme.git" main diff --git a/.github/workflows/mysql.yml b/.github/workflows/mysql.yml new file mode 100644 index 0000000..e29918d --- /dev/null +++ b/.github/workflows/mysql.yml @@ -0,0 +1,59 @@ +--- +name: CTFd MySQL CI + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + build: + + runs-on: ubuntu-latest + services: + mysql: + image: mysql:5.7 + env: + MYSQL_ROOT_PASSWORD: password + ports: + - 3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + redis: + image: redis + ports: + - 6379:6379 + + strategy: + matrix: + python-version: ['3.11'] + + name: Python ${{ matrix.python-version }} + steps: + - uses: actions/checkout@v2 + - name: Setup python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r development.txt + sudo yarn install --non-interactive + + - name: Test + run: | + sudo rm -f /etc/boto.cfg + make test + env: + AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE + AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + TESTING_DATABASE_URL: mysql+pymysql://root:password@localhost:${{ job.services.mysql.ports[3306] }}/ctfd + + - name: Codecov + uses: codecov/codecov-action@v5 + with: + file: ./coverage.xml diff --git a/.github/workflows/mysql8.yml b/.github/workflows/mysql8.yml new file mode 100644 index 0000000..49cf0d9 --- /dev/null +++ b/.github/workflows/mysql8.yml @@ -0,0 +1,59 @@ +--- +name: CTFd MySQL 8.0 CI + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + build: + + runs-on: ubuntu-latest + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: password + ports: + - 3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + redis: + image: redis + ports: + - 6379:6379 + + strategy: + matrix: + python-version: ['3.11'] + + name: Python ${{ matrix.python-version }} + steps: + - uses: actions/checkout@v2 + - name: Setup python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r development.txt + sudo yarn install --non-interactive + + - name: Test + run: | + sudo rm -f /etc/boto.cfg + make test + env: + AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE + AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + TESTING_DATABASE_URL: mysql+pymysql://root:password@localhost:${{ job.services.mysql.ports[3306] }}/ctfd + + - name: Codecov + uses: codecov/codecov-action@v5 + with: + file: ./coverage.xml diff --git a/.github/workflows/postgres.yml b/.github/workflows/postgres.yml new file mode 100644 index 0000000..15aa6f3 --- /dev/null +++ b/.github/workflows/postgres.yml @@ -0,0 +1,67 @@ +--- +name: CTFd Postgres CI + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + build: + + runs-on: ubuntu-latest + services: + postgres: + image: postgres + ports: + - 5432:5432 + env: + POSTGRES_HOST_AUTH_METHOD: trust + POSTGRES_DB: ctfd + POSTGRES_PASSWORD: password + # Set health checks to wait until postgres has started + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis + ports: + - 6379:6379 + + strategy: + matrix: + python-version: ['3.11'] + + name: Python ${{ matrix.python-version }} + steps: + - uses: actions/checkout@v2 + - name: Setup python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r development.txt + sudo yarn install --non-interactive + + - name: Test + run: | + sudo rm -f /etc/boto.cfg + make test + env: + AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE + AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + TESTING_DATABASE_URL: postgres://postgres:password@localhost:${{ job.services.postgres.ports[5432] }}/ctfd + + - name: Codecov + uses: codecov/codecov-action@v5 + with: + file: ./coverage.xml + diff --git a/.github/workflows/sqlite.yml b/.github/workflows/sqlite.yml new file mode 100644 index 0000000..64b049f --- /dev/null +++ b/.github/workflows/sqlite.yml @@ -0,0 +1,49 @@ +--- +name: CTFd SQLite CI + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ['3.11'] + + name: Python ${{ matrix.python-version }} + steps: + - uses: actions/checkout@v2 + - name: Setup python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + - name: Install dependencies + run: | + 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 + + - name: Test + run: | + sudo rm -f /etc/boto.cfg + make test + env: + AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE + AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + TESTING_DATABASE_URL: 'sqlite://' + + - name: Codecov + uses: codecov/codecov-action@v5 + with: + file: ./coverage.xml + diff --git a/.github/workflows/verify-themes.yml b/.github/workflows/verify-themes.yml new file mode 100644 index 0000000..5f5dacf --- /dev/null +++ b/.github/workflows/verify-themes.yml @@ -0,0 +1,38 @@ +--- +name: Theme Verification + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + build: + + runs-on: ubuntu-latest + + name: Theme Verification + steps: + - uses: actions/checkout@v2 + + - uses: actions/setup-node@v4 + with: + node-version: 20.19 + + - name: Verify admin theme + run: | + pwd + yarn install --non-interactive + yarn verify + working-directory: ./CTFd/themes/admin + + # TODO: Replace in 4.0 with deprecation of previous core theme + - name: Verify core theme + run: | + pwd + yarn install --non-interactive + yarn verify + working-directory: ./CTFd/themes/core diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ccc42b --- /dev/null +++ b/.gitignore @@ -0,0 +1,82 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +venv* +.venv* +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml + +# Translations +# TODO: CTFd 4.0 We should consider generating .mo files in a Docker image instead of saving them in git +# *.mo + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +.DS_Store + +*.db +*.log +*.log.* +.idea/ +.vscode/ +CTFd/static/uploads +CTFd/uploads +.data/ +.ctfd_secret_key +.*.swp + +# Vagrant +.vagrant + +# CTFd Exports +*.zip + +# JS +node_modules/ + +# Flask Profiler files +flask_profiler.sql diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..02d8a38 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,7 @@ +[settings] +multi_line_output=3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 +skip=migrations diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..9c4da8c --- /dev/null +++ b/.prettierignore @@ -0,0 +1,17 @@ +CTFd/themes/**/vendor/ +CTFd/themes/core-deprecated/ +CTFd/themes/core/static/ +CTFd/themes/core-beta/**/* +CTFd/themes/admin/static/**/* +*.html +*.njk +*.png +*.svg +*.ico +*.ai +*.svg +*.mp3 +*.webm +.pytest_cache +venv* +.venv* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6204374 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2390 @@ +# 3.8.1 / 2025-11-06 + +**Security** + +- Make challenge attempt ratelimit stricter +- Make reset password ratelimit stricter and apply per-account + +**General** + +- Integrates dynamic scoring into the standard challenge type + - All challenges will now have `initial`, `decay`, `minimum`, `function` columns available through the standard challenge type + - Scoring logic for challenges can be configured with the `function` field + - The dynamic value challenge plugin will remain supported until CTFd 4.0 +- Add `solved` solution visibility to challenge solutions + - This only allows a user to view a challenge's solution if they've solved the associated challenge + +**Admin Panel** + +- Add bulk editing for solution visibility in the Admin Panel Challenges page + +**API** + +- Change `GET /api/v1/solutions/[solution_id]` to return 404 if a solution is hidden instead of a 403 +- Add `/api/v1/challenges/[challenge_id]/solution` endpoint to check if a challenge solution is accessible + +**Themes** + +- CTFd.js has been bumped to `0.0.19` +- `challenges.js` now has additional functions `getSolutionState` and `setSolutionId` to allow the UI to determine if a solution is accessible + +**Deployment** + +- Fixes issues where preset admins would not be created +- Add `RUN_ID` config which specifies a token which will be used as a cache-buster URL parameter +- Add `EXTRA_CONFIGS_FORCE_TYPES` config to allow server admins to force types for configs specified in the `[extra]` section +- If `UPDATE_CHECK` is disabled the update prompt banner should be properly disabled +- Fix issue where users would be put into an infinite loop if confirm emails is enabled without having an email server configured + +**Translations** + +- Add Uzbek and Hebrew languages + +# 3.8.0 / 2025-09-04 + +**General** + +- Admins can now configure whether users can see their past submissions +- Admins can now store challenge solutions within CTFd to be viewed by users +- Participants can now leave upvotes/downvotes on challenges as well as their review of a challenge + - Ratings/Votes can be configured to be viewed by participants or only admins + - Reviews are only visible by admins +- Challenges now have the `logic` field which allows for challenge developers to control the flag collection behavior of a challenge: + - `any`: any flag is accepted for the challenge + - `all`: all flags for the challenge must be submitted + - `team`: all team members must submit any flag +- Max Attempts can now behave as a timeout instead of a lockout + - For example a user who submits 3 attempts will then be prevented from submitting another attempt for 5 minutes instead of being unable to submit entirely +- Social Shares for challenge completion are now enabled by default and admins may now control the social share template page +- Additional attempts after solving on challenges will now show if the submissions is correct/incorrect +- If email sending is available, email confirmation is enabled by default and users are nudged to complete email verification. +- Hints can now have a title that is shown before unlocking +- Hints now always require unlocking even if they require no cost + - Prevents accidental viewing and improves tracking of hint usage +- CTFd will now store a tracking event under `challenges.open` in the Tracking table when a challenge is opened for the first time by a user +- Challenges now report whether a flag is correct or incorrect even if the challenge has already been solved +- Fixes issue where admins could not download challenge files before CTF start when downloading anonymously + +**Admin Panel** + +- Added a matrix scoreboard to the Statistics page to show player progression through the CTF +- Added support for brackets in the Admin Panel scoreboard +- Added config option for minimum password length +- Added config option to control whether players can view their previous submissions +- Admins can now require users to change their password upon login +- Added config option to control Max Attempts behavior +- In the Admin Panel challenge preview, admins now only see free hints +- Fixed issue where the hint form was not resetting properly when creating multiple hints + +**API** + +- Added `/api/v1/users/me/submissions` for users to retrieve their own submissions +- Added `/api/v1/challenges/[challenge_id]/solutions` for users to retrieve challenge solutions +- Added `/api/v1/challenges/[challenge_id]/ratings` for users to submit ratings and for admins to retrieve them +- Added `ratings` and `rating` fields to the response of `/api/v1/challenges/[challenge_id]` +- Added `solution_id` to the response of `/api/v1/challenges/[challenge_id]` + - If no solution is available, the field is `null` +- Added `logic` field to the response of `/api/v1/challenges/[challenge_id]` +- Added `change_password` field to `/api/v1/users/[user_id]` when viewed as an admin +- Added `/api/v1/solutions` and `/api/v1/solutions/[solution_id]` endpoints +- `/api/v1/unlocks` is now also used to unlock solutions for user viewing + +**Deployment** + +- Added `PRESET_ADMIN_NAME`, `PRESET_ADMIN_EMAIL`, `PRESET_ADMIN_PASSWORD`, and `PRESET_ADMIN_TOKEN` to `config.ini` for pre-creating an admin user + - Useful for automated deployments and ensuring a known admin token exists +- Added `PRESET_CONFIGS` to `config.ini` for pre-setting server-side configs + - Useful for configuring CTFd without completing setup or using the API +- Added `EMAIL_CONFIRMATION_REQUIRE_INTERACTION` to `config.ini` to require additional interaction for email confirmation links + - Improves compatibility with certain anti-phishing defenses +- Email confirmation is now enabled whenever email sending is available +- Replaced `pybluemonday` with `nh3` (due to breakage in Python modules written in Golang) +- Updated Flask to 2.1.3 +- Updated Werkzeug to 2.2.3 + +**Plugins** + +- Challenge Type Plugins should now return a `ChallengeResponse` object instead of a `(status, message)` tuple + - Existing behavior is supported until CTFd 4.0 +- Added `BaseChallenge.partial` for challenge classes to indicate partial solves (for `all` flag logic) + +**Themes** + +- The `core-beta` theme has been promoted to `core` + - The `core-beta` repo has been replaced with the [core-theme repo](https://github.com/CTFd/core-theme). Future changes should be made in the main CTFd repo and these changes will be copied over to the core-theme repo. +- The previous `core` theme has been deprecated and renamed `core-deprecated` + +# 3.7.7 / 2025-04-14 + +**General** + +- Added ability to denylist/blacklist email domains from registering +- Hints can now include an optional title that is shown to users before unlocking + +**Admin Panel** + +- Challenge files now show the stored sha1sum + +**Deployment** + +- Fixed issue where the `/api/v1/scoreboard/top/` endpoint wouldn't cache different count values properly +- The `/api/v1/scoreboard/top/`endpoint will now return at most the top 50 accounts +- Updated gunicorn to 23.0.0 +- Updated Jinja2 to 3.1.6 + +# 3.7.6 / 2025-02-19 + +**Security** + +- Added the `TRUSTED_HOSTS` configuration to more easily restrict CTFd to valid host names + +**General** + +- Added language switcher on the main navigation bar +- Removed autocomplete=off from login, register, and reset password forms + +**Plugins** + +- Challenge type plugins can now raise `ChallengeCreateException` or `ChallengeUpdateException` to show input validation messages +- Plugins specifying a config route will now appear in the Admin Panel under the Plugins section + +**Translations** + +- Add Romanian, Greek, Finnish, Slovenian, Swedish languages + +# 3.7.5 / 2024-12-27 + +**Security** + +- Change confirmation and reset password emails to be single use instead of only expiring in 30 minutes + +**General** + +- Fix issue where users could set their own bracket after registration +- If a user or team do not have a password set we allow setting a password without providing a previous password confirmation +- Fix issue where dynamic challenges did not return their attribution over the API +- Language selection is now available in the main theme navigation bar + +**Admin Panel** + +- A point breakdown graph showing the amount of challenge points allocated to each category has been added to the Admin Panel +- Bracket ID and Bracket Name have been added to CSV scoreboard exports +- Fix issue with certain interactions in the Media Library + +**API** + +- Swagger specification has been updated to properly validate +- `/api/v1/flags/types` and `/api/v1/flags/types/` have been seperated into two seperate controllers + +**Deployment** + +- IP Tracking has been updated to only occur if we have not seen the IP before or on state changing methods +- Bump dependencies for `cmarkgfm` and `jinja2` + +# 3.7.4 / 2024-10-08 + +**Security** + +- Validate email length to be less than 320 chars to prevent Denial of Service in email validation + +**General** + +- Add attribution field to Challenges + +**Admin Panel** + +- Display brackets in the Admin Panel + +**Themes** + +- Display brackets for users/teams on listing pages and public/private pages +- Fix miscellaneous issues in core-beta +- Adds dark mode to core-beta theme +- Fix issue with long titles in challenge buttons +- Adds `type` and `extra` arguments to `Assets.js()` and default `defer` to `False` as `type="module"` automatically implies defer +- ECharts behavior for some graphs in core-beta can now be overriden using the following window objects `window.scoreboardChartOptions`, `window.teamScoreGraphChartOptions`, `window.userScoreGraphChartOptions` +- Update the scoreboard score graph to reflect the current active bracket changes + +**Deployment** + +- Add `.gitattributes` to keep LF line endings on .sh files under Windows +- Fix issues where None values are not cast to empty string +- Bump dependencies for `pybluemonday`, `requests`, and `boto3` + +# 3.7.3 / 2024-07-24 + +**Security** + +- Fix issue where challenge solves and account names could be seen despite accounts not being visible + +**Admin Panel** + +- Add a Localization section in the Config Panel +- Add the Default Language config in the Admin Panel to allow admins to configure a default language + - Previously CTFd would default to an auto-detected language specified by the user's browser. This setting allows for that default to be set by the admin instead of auto-detected. + +**Translations** + +- Fix issue where Simplified Chinese would be used instead of Traditional Chinese +- Update the language names for Simplified Chinese and Traditional Chinese for clarity +- Update Vietnamese translation +- Add Catalan translation + +# 3.7.2 / 2024-06-18 + +**Security** + +- Patches an issue where on certain browsers flags could be leaked with admin interaction on a malicious page + +**API** + +- Disable returning 404s in listing pages with pagination + - Instead of returning 404 these pages will now return 200 + - For API endpoints, the response will be a 200 with an empty listing instead of a 404 + +**Deployment** + +- CTFd will now add the `Cross-Origin-Opener-Policy` response header to all responses with the default value of `same-origin-allow-popups` +- Add `CROSS_ORIGIN_OPENER_POLICY` setting to control the `Cross-Origin-Opener-Policy` header + +# 3.7.1 / 2024-05-31 + +**Admin Panel** + +- The styling of the Config Panel has been updated to better organize different settings +- When switching user modes via the Admin Panel, all teams will now be removed +- Fix issues where importing CSVs comprised of JSON entries would fail +- Add `serializeJSON` function back into the Admin Panel + +**API** + +- The `/api/v1/exports/raw` API endpoint has been added to allow for exports to be generated via the API +- Update the ScoreboardDetail endpoint (`/api/v1/scoreboard/top/`) to return account URL, score, and bracket +- Add a query parameter to ScoreboardDetail endpoint (`/api/v1/scoreboard/top/`) to filter by bracket +- Return `function` field for DynamicValue challenges data read + +**General** + +- Add Italian and Vietnamese languages +- Switch to Crowdin for translations + +**Themes** + +- Add `defer` parameter to `Assets.js()` to allow controlling the defer attribute of inserted `' + url = url_for("views.themes_beta", theme=theme, path=entry) + html += f'' + return markup(html) + + def css(self, asset_key, theme=None): + if theme is None: + theme = ctf_theme() + asset = self.manifest(theme=theme)[asset_key] + entry = asset["file"] + url = url_for("views.themes_beta", theme=theme, path=entry) + return markup(f'') + + def file(self, asset_key, theme=None): + if theme is None: + theme = ctf_theme() + asset = self.manifest(theme=theme)[asset_key] + entry = asset["file"] + return url_for("views.themes_beta", theme=theme, path=entry) + + +Assets = _AssetsWrapper() diff --git a/CTFd/constants/config.py b/CTFd/constants/config.py new file mode 100644 index 0000000..ba66a12 --- /dev/null +++ b/CTFd/constants/config.py @@ -0,0 +1,74 @@ +import json + +from flask import url_for + +# TODO: CTFd 4.0. These imports previously specified in this file but have moved. We could consider removing these imports +from CTFd.constants.options import ( # noqa: F401 + AccountVisibilityTypes, + ChallengeVisibilityTypes, + ConfigTypes, + RegistrationVisibilityTypes, + ScoreVisibilityTypes, + UserModeTypes, +) +from CTFd.utils import get_config + + +class _ConfigsWrapper: + def __getattr__(self, attr): + return get_config(attr) + + @property + def ctf_name(self): + return get_config("ctf_name", default="CTFd") + + @property + def ctf_small_icon(self): + icon = get_config("ctf_small_icon") + if icon: + return url_for("views.files", path=icon) + return url_for("views.themes", path="img/favicon.ico") + + @property + def theme_header(self): + from CTFd.utils.helpers import markup + + return markup(get_config("theme_header", default="")) + + @property + def theme_footer(self): + from CTFd.utils.helpers import markup + + return markup(get_config("theme_footer", default="")) + + @property + def theme_settings(self): + try: + return json.loads(get_config("theme_settings", default="null")) + except json.JSONDecodeError: + return {"error": "invalid theme_settings"} + + @property + def tos_or_privacy(self): + tos = bool(get_config("tos_url") or get_config("tos_text")) + privacy = bool(get_config("privacy_url") or get_config("privacy_text")) + return tos or privacy + + @property + def tos_link(self): + return get_config("tos_url", default=url_for("views.tos")) + + @property + def privacy_link(self): + return get_config("privacy_url", default=url_for("views.privacy")) + + @property + def social_shares(self): + return get_config("social_shares", default=True) + + @property + def challenge_ratings(self): + return get_config("challenge_ratings", default="public") + + +Configs = _ConfigsWrapper() diff --git a/CTFd/constants/email.py b/CTFd/constants/email.py new file mode 100644 index 0000000..7f1a907 --- /dev/null +++ b/CTFd/constants/email.py @@ -0,0 +1,31 @@ +DEFAULT_VERIFICATION_EMAIL_SUBJECT = "Confirm your account for {ctf_name}" +DEFAULT_VERIFICATION_EMAIL_BODY = ( + "Welcome to {ctf_name}!\n\n" + "Click the following link to confirm and activate your account:\n" + "{url}" + "\n\n" + "If the link is not clickable, try copying and pasting it into your browser." +) +DEFAULT_SUCCESSFUL_REGISTRATION_EMAIL_SUBJECT = "Successfully registered for {ctf_name}" +DEFAULT_SUCCESSFUL_REGISTRATION_EMAIL_BODY = ( + "You've successfully registered for {ctf_name}!" +) +DEFAULT_USER_CREATION_EMAIL_SUBJECT = "Message from {ctf_name}" +DEFAULT_USER_CREATION_EMAIL_BODY = ( + "A new account has been created for you for {ctf_name} at {url}. \n\n" + "Username: {name}\n" + "Password: {password}" +) +DEFAULT_PASSWORD_RESET_SUBJECT = "Password Reset Request from {ctf_name}" +DEFAULT_PASSWORD_RESET_BODY = ( + "Did you initiate a password reset on {ctf_name}? " + "If you didn't initiate this request you can ignore this email. \n\n" + "Click the following link to reset your password:\n{url}\n\n" + "If the link is not clickable, try copying and pasting it into your browser." +) +DEFAULT_PASSWORD_CHANGE_ALERT_SUBJECT = "Password Change Confirmation for {ctf_name}" +DEFAULT_PASSWORD_CHANGE_ALERT_BODY = ( + "Your password for {ctf_name} has been changed.\n\n" + "If you didn't request a password change you can reset your password here:\n{url}\n\n" + "If the link is not clickable, try copying and pasting it into your browser." +) diff --git a/CTFd/constants/languages.py b/CTFd/constants/languages.py new file mode 100644 index 0000000..159c565 --- /dev/null +++ b/CTFd/constants/languages.py @@ -0,0 +1,61 @@ +from CTFd.constants import RawEnum + + +class Languages(str, RawEnum): + ENGLISH = "en" + GERMAN = "de" + POLISH = "pl" + SPANISH = "es" + ARABIC = "ar" + CHINESE = "zh_CN" + TAIWANESE = "zh_TW" + FRENCH = "fr" + KOREAN = "ko" + RUSSIAN = "ru" + BRAZILIAN_PORTUGESE = "pt_BR" + SLOVAK = "sk" + JAPANESE = "ja" + ITALIAN = "it" + VIETNAMESE = "vi" + CATALAN = "ca" + GREEK = "el" + FINNISH = "fi" + ROMANIAN = "ro" + SLOVENIAN = "sl" + SWEDISH = "sv" + HEBREW = "he" + UZBEK = "uz" + + +LANGUAGE_NAMES = { + "en": "English", + "de": "Deutsch", + "pl": "Polski", + "es": "Español", + "ar": "اَلْعَرَبِيَّةُ", + "zh_CN": "简体中文", + "zh_TW": "繁體中文", + "fr": "Français", + "ko": "한국어", + "ru": "русский язык", + "pt_BR": "Português do Brasil", + "sk": "Slovenský jazyk", + "ja": "日本語", + "it": "Italiano", + "vi": "tiếng Việt", + "ca": "Català", + "el": "Ελληνικά", + "fi": "Suomi", + "ro": "Română", + "sl": "Slovenščina", + "sv": "Svenska", + "he": "עברית", + "uz": "oʻzbekcha", +} + +SELECT_LANGUAGE_LIST = [("", "")] + [ + (str(lang), LANGUAGE_NAMES.get(str(lang))) for lang in Languages +] + +Languages.names = LANGUAGE_NAMES +Languages.select_list = SELECT_LANGUAGE_LIST diff --git a/CTFd/constants/options.py b/CTFd/constants/options.py new file mode 100644 index 0000000..fdf2d4c --- /dev/null +++ b/CTFd/constants/options.py @@ -0,0 +1,43 @@ +from CTFd.constants import JinjaEnum, RawEnum + + +class ConfigTypes(str, RawEnum): + CHALLENGE_VISIBILITY = "challenge_visibility" + SCORE_VISIBILITY = "score_visibility" + ACCOUNT_VISIBILITY = "account_visibility" + REGISTRATION_VISIBILITY = "registration_visibility" + + +@JinjaEnum +class UserModeTypes(str, RawEnum): + USERS = "users" + TEAMS = "teams" + + +@JinjaEnum +class ChallengeVisibilityTypes(str, RawEnum): + PUBLIC = "public" + PRIVATE = "private" + ADMINS = "admins" + + +@JinjaEnum +class ScoreVisibilityTypes(str, RawEnum): + PUBLIC = "public" + PRIVATE = "private" + HIDDEN = "hidden" + ADMINS = "admins" + + +@JinjaEnum +class AccountVisibilityTypes(str, RawEnum): + PUBLIC = "public" + PRIVATE = "private" + ADMINS = "admins" + + +@JinjaEnum +class RegistrationVisibilityTypes(str, RawEnum): + PUBLIC = "public" + PRIVATE = "private" + MLC = "mlc" diff --git a/CTFd/constants/plugins.py b/CTFd/constants/plugins.py new file mode 100644 index 0000000..3188746 --- /dev/null +++ b/CTFd/constants/plugins.py @@ -0,0 +1,54 @@ +from flask import current_app + +from CTFd.plugins import get_admin_plugin_menu_bar, get_user_page_menu_bar +from CTFd.utils.helpers import markup +from CTFd.utils.plugins import get_registered_scripts, get_registered_stylesheets + + +class _PluginWrapper: + @property + def scripts(self): + application_root = current_app.config.get("APPLICATION_ROOT") + subdir = application_root != "/" + scripts = [] + for script in get_registered_scripts(): + if script.startswith("http"): + scripts.append(f'') + elif subdir: + scripts.append( + f'' + ) + else: + scripts.append(f'') + return markup("\n".join(scripts)) + + @property + def styles(self): + application_root = current_app.config.get("APPLICATION_ROOT") + subdir = application_root != "/" + _styles = [] + for stylesheet in get_registered_stylesheets(): + if stylesheet.startswith("http"): + _styles.append( + f'' + ) + elif subdir: + _styles.append( + f'' + ) + else: + _styles.append( + f'' + ) + return markup("\n".join(_styles)) + + @property + def user_menu_pages(self): + return get_user_page_menu_bar() + + @property + def admin_menu_pages(self): + return get_admin_plugin_menu_bar() + + +Plugins = _PluginWrapper() diff --git a/CTFd/constants/sessions.py b/CTFd/constants/sessions.py new file mode 100644 index 0000000..a5417a5 --- /dev/null +++ b/CTFd/constants/sessions.py @@ -0,0 +1,18 @@ +from flask import session + + +class _SessionWrapper: + @property + def id(self): + return session.get("id", 0) + + @property + def nonce(self): + return session.get("nonce") + + @property + def hash(self): + return session.get("hash") + + +Session = _SessionWrapper() diff --git a/CTFd/constants/setup.py b/CTFd/constants/setup.py new file mode 100644 index 0000000..b09ca31 --- /dev/null +++ b/CTFd/constants/setup.py @@ -0,0 +1,21 @@ +from CTFd.constants.options import ( + AccountVisibilityTypes, + ChallengeVisibilityTypes, + RegistrationVisibilityTypes, + ScoreVisibilityTypes, + UserModeTypes, +) +from CTFd.constants.themes import DEFAULT_THEME + +DEFAULTS = { + # General Settings + "ctf_name": "CTFd", + "user_mode": UserModeTypes.USERS, + # Visual/Style Settings + "ctf_theme": DEFAULT_THEME, + # Visibility Settings + "challenge_visibility": ChallengeVisibilityTypes.PRIVATE, + "registration_visibility": RegistrationVisibilityTypes.PUBLIC, + "score_visibility": ScoreVisibilityTypes.PUBLIC, + "account_visibility": AccountVisibilityTypes.PUBLIC, +} diff --git a/CTFd/constants/static.py b/CTFd/constants/static.py new file mode 100644 index 0000000..93a380f --- /dev/null +++ b/CTFd/constants/static.py @@ -0,0 +1,14 @@ +from CTFd.constants import JinjaEnum, RawEnum + + +@JinjaEnum +class CacheKeys(str, RawEnum): + PUBLIC_SCOREBOARD_TABLE = "public_scoreboard_table" + + +# Placeholder object. Not used, just imported to force initialization of any Enums here +class _StaticsWrapper: + pass + + +Static = _StaticsWrapper() diff --git a/CTFd/constants/teams.py b/CTFd/constants/teams.py new file mode 100644 index 0000000..ce16225 --- /dev/null +++ b/CTFd/constants/teams.py @@ -0,0 +1,46 @@ +from collections import namedtuple + +# TODO: CTFd 4.0. Consider changing to a dataclass +TeamAttrsFields = [ + "id", + "oauth_id", + "name", + "email", + "secret", + "website", + "affiliation", + "country", + "bracket_id", + "hidden", + "banned", + "captain_id", + "created", +] +TeamAttrs = namedtuple( + "TeamAttrs", + TeamAttrsFields, + defaults=(None,) * len(TeamAttrsFields), +) + + +class _TeamAttrsWrapper: + def __getattr__(self, attr): + from CTFd.utils.user import get_current_team_attrs + + attrs = get_current_team_attrs() + return getattr(attrs, attr, None) + + @property + def place(self): + from CTFd.utils.user import get_team_place + + return get_team_place(team_id=self.id) + + @property + def score(self): + from CTFd.utils.user import get_team_score + + return get_team_score(team_id=self.id) + + +Team = _TeamAttrsWrapper() diff --git a/CTFd/constants/themes.py b/CTFd/constants/themes.py new file mode 100644 index 0000000..46c9285 --- /dev/null +++ b/CTFd/constants/themes.py @@ -0,0 +1,2 @@ +ADMIN_THEME = "admin" +DEFAULT_THEME = "core" diff --git a/CTFd/constants/users.py b/CTFd/constants/users.py new file mode 100644 index 0000000..99b667f --- /dev/null +++ b/CTFd/constants/users.py @@ -0,0 +1,48 @@ +from collections import namedtuple + +# TODO: CTFd 4.0. Consider changing to a dataclass +UserAttrsFields = [ + "id", + "oauth_id", + "name", + "email", + "type", + "secret", + "website", + "affiliation", + "country", + "bracket_id", + "hidden", + "banned", + "verified", + "language", + "team_id", + "created", + "change_password", +] +UserAttrs = namedtuple( + "UserAttrs", UserAttrsFields, defaults=(None,) * len(UserAttrsFields) +) + + +class _UserAttrsWrapper: + def __getattr__(self, attr): + from CTFd.utils.user import get_current_user_attrs + + attrs = get_current_user_attrs() + return getattr(attrs, attr, None) + + @property + def place(self): + from CTFd.utils.user import get_user_place + + return get_user_place(user_id=self.id) + + @property + def score(self): + from CTFd.utils.user import get_user_score + + return get_user_score(user_id=self.id) + + +User = _UserAttrsWrapper() diff --git a/CTFd/errors.py b/CTFd/errors.py new file mode 100644 index 0000000..5e65491 --- /dev/null +++ b/CTFd/errors.py @@ -0,0 +1,21 @@ +import jinja2.exceptions +from flask import render_template +from werkzeug.exceptions import InternalServerError + + +def render_error(error): + if ( + isinstance(error, InternalServerError) + and error.description == InternalServerError.description + ): + error.description = "An Internal Server Error has occurred" + try: + return ( + render_template( + "errors/{}.html".format(error.code), + error=error.description, + ), + error.code, + ) + except jinja2.exceptions.TemplateNotFound: + return error.get_response() diff --git a/CTFd/events/__init__.py b/CTFd/events/__init__.py new file mode 100644 index 0000000..57c8cab --- /dev/null +++ b/CTFd/events/__init__.py @@ -0,0 +1,26 @@ +from flask import Blueprint, Response, current_app, stream_with_context + +from CTFd.models import db +from CTFd.utils import get_app_config +from CTFd.utils.decorators import authed_only, ratelimit + +events = Blueprint("events", __name__) + + +@events.route("/events") +@authed_only +@ratelimit(method="GET", limit=150, interval=60) +def subscribe(): + @stream_with_context + def gen(): + for event in current_app.events_manager.subscribe(): + yield str(event) + + enabled = get_app_config("SERVER_SENT_EVENTS") + if enabled is False: + return ("", 204) + + # Close the db session to avoid OperationalError with MySQL connection errors + db.session.close() + + return Response(gen(), mimetype="text/event-stream") diff --git a/CTFd/exceptions/__init__.py b/CTFd/exceptions/__init__.py new file mode 100644 index 0000000..9788f12 --- /dev/null +++ b/CTFd/exceptions/__init__.py @@ -0,0 +1,14 @@ +class UserNotFoundException(Exception): + pass + + +class UserTokenExpiredException(Exception): + pass + + +class TeamTokenExpiredException(Exception): + pass + + +class TeamTokenInvalidException(Exception): + pass diff --git a/CTFd/exceptions/challenges.py b/CTFd/exceptions/challenges.py new file mode 100644 index 0000000..9518bd4 --- /dev/null +++ b/CTFd/exceptions/challenges.py @@ -0,0 +1,10 @@ +class ChallengeCreateException(Exception): + pass + + +class ChallengeUpdateException(Exception): + pass + + +class ChallengeSolveException(Exception): + pass diff --git a/CTFd/exceptions/email.py b/CTFd/exceptions/email.py new file mode 100644 index 0000000..8e94d06 --- /dev/null +++ b/CTFd/exceptions/email.py @@ -0,0 +1,6 @@ +class UserConfirmTokenInvalidException(Exception): + pass + + +class UserResetPasswordTokenInvalidException(Exception): + pass diff --git a/CTFd/fonts/OFL.txt b/CTFd/fonts/OFL.txt new file mode 100644 index 0000000..2e76eef --- /dev/null +++ b/CTFd/fonts/OFL.txt @@ -0,0 +1,88 @@ +Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans) + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/CTFd/fonts/OpenSans-Bold.ttf b/CTFd/fonts/OpenSans-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..b7fadfa4aa59c43b38f7efd87cd438fab49679ca GIT binary patch literal 147264 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 P*DZObLzDocOV;{7XLP32 literal 0 HcmV?d00001 diff --git a/CTFd/forms/__init__.py b/CTFd/forms/__init__.py new file mode 100644 index 0000000..51809a2 --- /dev/null +++ b/CTFd/forms/__init__.py @@ -0,0 +1,51 @@ +from wtforms import Form +from wtforms.csrf.core import CSRF + + +class CTFdCSRF(CSRF): + def generate_csrf_token(self, csrf_token_field): + from flask import session + + return session.get("nonce") + + +class BaseForm(Form): + class Meta: + csrf = True + csrf_class = CTFdCSRF + csrf_field_name = "nonce" + + +class _FormsWrapper: + pass + + +Forms = _FormsWrapper() + +from CTFd.forms import auth # noqa: I001 isort:skip +from CTFd.forms import self # noqa: I001 isort:skip +from CTFd.forms import teams # noqa: I001 isort:skip +from CTFd.forms import setup # noqa: I001 isort:skip +from CTFd.forms import submissions # noqa: I001 isort:skip +from CTFd.forms import users # noqa: I001 isort:skip +from CTFd.forms import challenges # noqa: I001 isort:skip +from CTFd.forms import language # noqa: I001 isort:skip +from CTFd.forms import notifications # noqa: I001 isort:skip +from CTFd.forms import config # noqa: I001 isort:skip +from CTFd.forms import pages # noqa: I001 isort:skip +from CTFd.forms import awards # noqa: I001 isort:skip +from CTFd.forms import email # noqa: I001 isort:skip + +Forms.auth = auth +Forms.self = self +Forms.teams = teams +Forms.setup = setup +Forms.submissions = submissions +Forms.users = users +Forms.challenges = challenges +Forms.language = language +Forms.notifications = notifications +Forms.config = config +Forms.pages = pages +Forms.awards = awards +Forms.email = email diff --git a/CTFd/forms/auth.py b/CTFd/forms/auth.py new file mode 100644 index 0000000..6de356a --- /dev/null +++ b/CTFd/forms/auth.py @@ -0,0 +1,88 @@ +from flask_babel import lazy_gettext as _l +from wtforms import PasswordField, StringField +from wtforms.fields.html5 import EmailField +from wtforms.validators import InputRequired + +from CTFd.forms import BaseForm +from CTFd.forms.fields import SubmitField +from CTFd.forms.users import ( + attach_custom_user_fields, + attach_registration_code_field, + attach_user_bracket_field, + build_custom_user_fields, + build_registration_code_field, + build_user_bracket_field, +) +from CTFd.utils import get_config + + +def RegistrationForm(*args, **kwargs): + password_min_length = int(get_config("password_min_length", default=0)) + password_description = _l("Password used to log into your account") + if password_min_length: + password_description += _l( + f" (Must be at least {password_min_length} characters)" + ) + + class _RegistrationForm(BaseForm): + name = StringField( + _l("User Name"), + description="Your username on the site", + validators=[InputRequired()], + render_kw={"autofocus": True}, + ) + email = EmailField( + _l("Email"), + description="Never shown to the public", + validators=[InputRequired()], + ) + password = PasswordField( + _l("Password"), + description=password_description, + validators=[InputRequired()], + ) + submit = SubmitField(_l("Submit")) + + @property + def extra(self): + return ( + build_custom_user_fields( + self, include_entries=False, blacklisted_items=() + ) + + build_registration_code_field(self) + + build_user_bracket_field(self) + ) + + attach_custom_user_fields(_RegistrationForm) + attach_registration_code_field(_RegistrationForm) + attach_user_bracket_field(_RegistrationForm) + + return _RegistrationForm(*args, **kwargs) + + +class LoginForm(BaseForm): + name = StringField( + _l("User Name or Email"), + validators=[InputRequired()], + render_kw={"autofocus": True}, + ) + password = PasswordField(_l("Password"), validators=[InputRequired()]) + submit = SubmitField(_l("Submit")) + + +class ConfirmForm(BaseForm): + submit = SubmitField(_l("Send Confirmation Email")) + + +class ResetPasswordRequestForm(BaseForm): + email = EmailField( + _l("Email"), validators=[InputRequired()], render_kw={"autofocus": True} + ) + submit = SubmitField(_l("Submit")) + + +class ResetPasswordForm(BaseForm): + password = PasswordField( + _l("Password"), validators=[InputRequired()], render_kw={"autofocus": True} + ) + submit = SubmitField(_l("Submit")) diff --git a/CTFd/forms/awards.py b/CTFd/forms/awards.py new file mode 100644 index 0000000..32819fa --- /dev/null +++ b/CTFd/forms/awards.py @@ -0,0 +1,30 @@ +from wtforms import RadioField, StringField, TextAreaField +from wtforms.fields.html5 import IntegerField + +from CTFd.forms import BaseForm +from CTFd.forms.fields import SubmitField + + +class AwardCreationForm(BaseForm): + name = StringField("Name") + value = IntegerField("Value") + category = StringField("Category") + description = TextAreaField("Description") + submit = SubmitField("Create") + icon = RadioField( + "Icon", + choices=[ + ("", "None"), + ("shield", "Shield"), + ("bug", "Bug"), + ("crown", "Crown"), + ("crosshairs", "Crosshairs"), + ("ban", "Ban"), + ("lightning", "Lightning"), + ("skull", "Skull"), + ("brain", "Brain"), + ("code", "Code"), + ("cowboy", "Cowboy"), + ("angry", "Angry"), + ], + ) diff --git a/CTFd/forms/challenges.py b/CTFd/forms/challenges.py new file mode 100644 index 0000000..e5b5a4c --- /dev/null +++ b/CTFd/forms/challenges.py @@ -0,0 +1,30 @@ +from wtforms import MultipleFileField, SelectField, StringField +from wtforms.validators import InputRequired + +from CTFd.forms import BaseForm +from CTFd.forms.fields import SubmitField + + +class ChallengeSearchForm(BaseForm): + field = SelectField( + "Search Field", + choices=[ + ("name", "Name"), + ("id", "ID"), + ("category", "Category"), + ("type", "Type"), + ], + default="name", + validators=[InputRequired()], + ) + q = StringField("Parameter", validators=[InputRequired()]) + submit = SubmitField("Search") + + +class ChallengeFilesUploadForm(BaseForm): + file = MultipleFileField( + "Upload Files", + description="Attach multiple files using Control+Click or Cmd+Click.", + validators=[InputRequired()], + ) + submit = SubmitField("Upload") diff --git a/CTFd/forms/config.py b/CTFd/forms/config.py new file mode 100644 index 0000000..d182e62 --- /dev/null +++ b/CTFd/forms/config.py @@ -0,0 +1,344 @@ +from wtforms import BooleanField, FileField, SelectField, StringField, TextAreaField +from wtforms.fields.html5 import IntegerField, URLField +from wtforms.widgets.html5 import NumberInput + +from CTFd.constants.config import ( + AccountVisibilityTypes, + ChallengeVisibilityTypes, + RegistrationVisibilityTypes, + ScoreVisibilityTypes, +) +from CTFd.constants.email import ( + DEFAULT_PASSWORD_CHANGE_ALERT_BODY, + DEFAULT_PASSWORD_CHANGE_ALERT_SUBJECT, + DEFAULT_PASSWORD_RESET_BODY, + DEFAULT_PASSWORD_RESET_SUBJECT, + DEFAULT_SUCCESSFUL_REGISTRATION_EMAIL_BODY, + DEFAULT_SUCCESSFUL_REGISTRATION_EMAIL_SUBJECT, + DEFAULT_USER_CREATION_EMAIL_BODY, + DEFAULT_USER_CREATION_EMAIL_SUBJECT, + DEFAULT_VERIFICATION_EMAIL_BODY, + DEFAULT_VERIFICATION_EMAIL_SUBJECT, +) +from CTFd.constants.languages import SELECT_LANGUAGE_LIST +from CTFd.forms import BaseForm +from CTFd.forms.fields import SubmitField +from CTFd.utils.csv import get_dumpable_tables +from CTFd.utils.social import BASE_TEMPLATE + + +class ResetInstanceForm(BaseForm): + accounts = BooleanField( + "Accounts", + description="Deletes all user and team accounts and their associated information", + ) + submissions = BooleanField( + "Submissions", + description="Deletes all records that accounts gained points or took an action", + ) + challenges = BooleanField( + "Challenges", description="Deletes all challenges and associated data" + ) + pages = BooleanField( + "Pages", description="Deletes all pages and their associated files" + ) + notifications = BooleanField( + "Notifications", description="Deletes all notifications" + ) + submit = SubmitField("Reset CTF") + + +class AccountSettingsForm(BaseForm): + domain_whitelist = StringField( + "Email Domain Allowlist", + description="Comma-seperated list of allowable email domains which users can register under (e.g. examplectf.com, example.com, *.example.com)", + ) + domain_blacklist = StringField( + "Email Domain Blocklist", + description="Comma-seperated list of disallowed email domains which users cannot register under (e.g. examplectf.com, example.com, *.example.com)", + ) + team_creation = SelectField( + "Team Creation", + description="Control whether users can create their own teams (Teams mode only)", + choices=[("true", "Enabled"), ("false", "Disabled")], + default="true", + ) + team_size = IntegerField( + widget=NumberInput(min=0), + description="Maximum number of users per team (Teams mode only)", + ) + num_teams = IntegerField( + "Maximum Number of Teams", + widget=NumberInput(min=0), + description="Maximum number of teams allowed to register with this CTF (Teams mode only)", + ) + num_users = IntegerField( + "Maximum Number of Users", + widget=NumberInput(min=0), + description="Maximum number of user accounts allowed to register with this CTF", + ) + password_min_length = IntegerField( + "Minimum Password Length for Users", + widget=NumberInput(min=0), + description="Minimum Password Length for Users", + ) + verify_emails = SelectField( + "Verify Emails", + description="Control whether users must confirm their email addresses before playing", + choices=[("true", "Enabled"), ("false", "Disabled")], + default="false", + ) + team_disbanding = SelectField( + "Team Disbanding", + description="Control whether team captains are allowed to disband their own teams", + choices=[ + ("inactive_only", "Enabled for Inactive Teams"), + ("disabled", "Disabled"), + ], + default="inactive_only", + ) + name_changes = SelectField( + "Name Changes", + description="Control whether users and teams can change their names", + choices=[("true", "Enabled"), ("false", "Disabled")], + default="true", + ) + incorrect_submissions_per_min = IntegerField( + "Incorrect Submissions per Minute", + widget=NumberInput(min=1), + description="Number of submissions allowed per minute for flag bruteforce protection (default: 10)", + ) + + submit = SubmitField("Update") + + +class ExportCSVForm(BaseForm): + table = SelectField("Database Table", choices=get_dumpable_tables()) + submit = SubmitField("Download CSV") + + +class ImportCSVForm(BaseForm): + csv_type = SelectField( + "CSV Type", + choices=[("users", "Users"), ("teams", "Teams"), ("challenges", "Challenges")], + description="Type of CSV data", + ) + csv_file = FileField("CSV File", description="CSV file contents") + + +class SocialSettingsForm(BaseForm): + social_shares = SelectField( + "Social Shares", + description="Enable or Disable social sharing links for challenge solves", + choices=[("true", "Enabled"), ("false", "Disabled")], + default="true", + ) + social_share_solve_template = TextAreaField( + "Social Share Solve Template", + description="HTML for Share Template", + default=BASE_TEMPLATE, + ) + submit = SubmitField("Update") + + +class LegalSettingsForm(BaseForm): + tos_url = URLField( + "Terms of Service URL", + description="External URL to a Terms of Service document hosted elsewhere", + ) + tos_text = TextAreaField( + "Terms of Service", + description="Text shown on the Terms of Service page", + ) + privacy_url = URLField( + "Privacy Policy URL", + description="External URL to a Privacy Policy document hosted elsewhere", + ) + privacy_text = TextAreaField( + "Privacy Policy", + description="Text shown on the Privacy Policy page", + ) + submit = SubmitField("Update") + + +class ChallengeSettingsForm(BaseForm): + view_self_submissions = SelectField( + "View Self Submissions", + description="Allow users to view their previous submissions", + choices=[("true", "Enabled"), ("false", "Disabled")], + default="false", + ) + max_attempts_behavior = SelectField( + "Max Attempts Behavior", + description="Set Max Attempts behavior to be a lockout or a timeout", + choices=[("lockout", "lockout"), ("timeout", "timeout")], + default="lockout", + ) + max_attempts_timeout = IntegerField( + "Max Attempts Timeout Duration", + description="How long the timeout lasts in seconds for max attempts (if set to timeout). Default is 300 seconds", + default=300, + ) + hints_free_public_access = SelectField( + "Hints Free Public Access", + description="Control whether users must be logged in to see free hints (hints without cost or requirements)", + choices=[("true", "Enabled"), ("false", "Disabled")], + default="false", + ) + challenge_ratings = SelectField( + "Challenge Ratings", + description="Control who can see and submit challenge ratings", + choices=[ + ("public", "Public (users can submit ratings and see aggregated ratings)"), + ( + "private", + "Private (users can submit ratings but cannot see aggregated ratings)", + ), + ( + "disabled", + "Disabled (users cannot submit ratings or see aggregated ratings)", + ), + ], + default="public", + ) + + +class VisibilitySettingsForm(BaseForm): + challenge_visibility = SelectField( + "Challenge Visibility", + description="Control whether users must be logged in to see challenges", + choices=[ + (ChallengeVisibilityTypes.PUBLIC, "Public"), + (ChallengeVisibilityTypes.PRIVATE, "Private"), + (ChallengeVisibilityTypes.ADMINS, "Admins Only"), + ], + default=ChallengeVisibilityTypes.PRIVATE, + ) + account_visibility = SelectField( + "Account Visibility", + description="Control whether accounts (users & teams) are shown to everyone, only to authenticated users, or only to admins", + choices=[ + (AccountVisibilityTypes.PUBLIC, "Public"), + (AccountVisibilityTypes.PRIVATE, "Private"), + (AccountVisibilityTypes.ADMINS, "Admins Only"), + ], + default=AccountVisibilityTypes.PUBLIC, + ) + score_visibility = SelectField( + "Score Visibility", + description="Control whether solves/score are shown to the public, to logged in users, hidden to all non-admins, or only shown to admins", + choices=[ + (ScoreVisibilityTypes.PUBLIC, "Public"), + (ScoreVisibilityTypes.PRIVATE, "Private"), + (ScoreVisibilityTypes.HIDDEN, "Hidden"), + (ScoreVisibilityTypes.ADMINS, "Admins Only"), + ], + default=ScoreVisibilityTypes.PUBLIC, + ) + registration_visibility = SelectField( + "Registration Visibility", + description="Control whether registration is enabled for everyone or disabled", + choices=[ + (RegistrationVisibilityTypes.PUBLIC, "Public"), + (RegistrationVisibilityTypes.PRIVATE, "Private"), + (RegistrationVisibilityTypes.MLC, "MajorLeagueCyber Only"), + ], + default=RegistrationVisibilityTypes.PUBLIC, + ) + + +class LocalizationForm(BaseForm): + default_locale = SelectField( + "Default Language", + description="Language to use if a user does not specify a language in their account settings. By default, CTFd will auto-detect the user's preferred language.", + choices=SELECT_LANGUAGE_LIST, + ) + + +class EmailSettingsForm(BaseForm): + # Mail Server Settings + mailfrom_addr = StringField( + "Mail From Address", description="Email address used to send email" + ) + mail_server = StringField( + "Mail Server Address", + description="Change the email server used by CTFd to send email", + ) + mail_port = IntegerField( + "Mail Server Port", + widget=NumberInput(min=1, max=65535), + description="Mail Server Port", + ) + mail_useauth = BooleanField("Use Mail Server Username and Password") + mail_username = StringField("Username", description="Mail Server Account Username") + mail_password = StringField("Password", description="Mail Server Account Password") + mail_ssl = BooleanField("TLS/SSL") + mail_tls = BooleanField("STARTTLS") + + # Mailgun Settings (deprecated) + mailgun_base_url = StringField( + "Mailgun API Base URL", description="Mailgun API Base URL" + ) + mailgun_api_key = StringField("Mailgun API Key", description="Mailgun API Key") + + # Registration Email + successful_registration_email_subject = StringField( + "Subject", + description="Subject line for registration confirmation email", + default=DEFAULT_SUCCESSFUL_REGISTRATION_EMAIL_SUBJECT, + ) + successful_registration_email_body = TextAreaField( + "Body", + description="Email body sent to users after they've finished registering", + default=DEFAULT_SUCCESSFUL_REGISTRATION_EMAIL_BODY, + ) + + # Verification Email + verification_email_subject = StringField( + "Subject", + description="Subject line for account verification email", + default=DEFAULT_VERIFICATION_EMAIL_SUBJECT, + ) + verification_email_body = TextAreaField( + "Body", + description="Email body sent to users to confirm their account at the address they provided during registration", + default=DEFAULT_VERIFICATION_EMAIL_BODY, + ) + + # Account Details Email + user_creation_email_subject = StringField( + "Subject", + description="Subject line for new account details email", + default=DEFAULT_USER_CREATION_EMAIL_SUBJECT, + ) + user_creation_email_body = TextAreaField( + "Body", + description="Email body sent to new users (manually created by an admin) with their initial account details", + default=DEFAULT_USER_CREATION_EMAIL_BODY, + ) + + # Password Reset Email + password_reset_subject = StringField( + "Subject", + description="Subject line for password reset request email", + default=DEFAULT_PASSWORD_RESET_SUBJECT, + ) + password_reset_body = TextAreaField( + "Body", + description="Email body sent when a user requests a password reset", + default=DEFAULT_PASSWORD_RESET_BODY, + ) + + # Password Reset Confirmation Email + password_change_alert_subject = StringField( + "Subject", + description="Subject line for password reset confirmation email", + default=DEFAULT_PASSWORD_CHANGE_ALERT_SUBJECT, + ) + password_change_alert_body = TextAreaField( + "Body", + description="Email body sent when a user successfully resets their password", + default=DEFAULT_PASSWORD_CHANGE_ALERT_BODY, + ) + + submit = SubmitField("Update") diff --git a/CTFd/forms/email.py b/CTFd/forms/email.py new file mode 100644 index 0000000..889cfac --- /dev/null +++ b/CTFd/forms/email.py @@ -0,0 +1,10 @@ +from wtforms import TextAreaField +from wtforms.validators import InputRequired + +from CTFd.forms import BaseForm +from CTFd.forms.fields import SubmitField + + +class SendEmailForm(BaseForm): + text = TextAreaField("Message", validators=[InputRequired()]) + submit = SubmitField("Send") diff --git a/CTFd/forms/fields.py b/CTFd/forms/fields.py new file mode 100644 index 0000000..4c0a5bc --- /dev/null +++ b/CTFd/forms/fields.py @@ -0,0 +1,17 @@ +from wtforms import SubmitField as _SubmitField + + +class SubmitField(_SubmitField): + """ + This custom SubmitField exists because wtforms is dumb. + + See https://github.com/wtforms/wtforms/issues/205, https://github.com/wtforms/wtforms/issues/36 + The .submit() handler in JS will break if the form has an input with the name or id of "submit" so submit fields need to be changed. + """ + + def __init__(self, *args, **kwargs): + name = kwargs.pop("name", "_submit") + super().__init__(*args, **kwargs) + if self.name == "submit" or name: + self.id = name + self.name = name diff --git a/CTFd/forms/language.py b/CTFd/forms/language.py new file mode 100644 index 0000000..00c7939 --- /dev/null +++ b/CTFd/forms/language.py @@ -0,0 +1,19 @@ +from wtforms import RadioField + +from CTFd.constants.languages import LANGUAGE_NAMES +from CTFd.forms import BaseForm + + +def LanguageForm(*args, **kwargs): + from CTFd.utils.user import get_locale + + class _LanguageForm(BaseForm): + """Language form for only switching langauge without rendering all profile settings""" + + language = RadioField( + "", + choices=LANGUAGE_NAMES.items(), + default=get_locale(), + ) + + return _LanguageForm(*args, **kwargs) diff --git a/CTFd/forms/notifications.py b/CTFd/forms/notifications.py new file mode 100644 index 0000000..c590b6d --- /dev/null +++ b/CTFd/forms/notifications.py @@ -0,0 +1,26 @@ +from wtforms import BooleanField, RadioField, StringField, TextAreaField +from wtforms.validators import InputRequired + +from CTFd.forms import BaseForm +from CTFd.forms.fields import SubmitField + + +class NotificationForm(BaseForm): + title = StringField("Title", description="Notification title") + content = TextAreaField( + "Content", + description="Notification contents. Can consist of HTML and/or Markdown.", + ) + type = RadioField( + "Notification Type", + choices=[("toast", "Toast"), ("alert", "Alert"), ("background", "Background")], + default="toast", + description="What type of notification users receive", + validators=[InputRequired()], + ) + sound = BooleanField( + "Play Sound", + default=True, + description="Play sound for users when they receive the notification", + ) + submit = SubmitField("Submit") diff --git a/CTFd/forms/pages.py b/CTFd/forms/pages.py new file mode 100644 index 0000000..0417767 --- /dev/null +++ b/CTFd/forms/pages.py @@ -0,0 +1,48 @@ +from wtforms import ( + BooleanField, + HiddenField, + MultipleFileField, + SelectField, + StringField, + TextAreaField, +) +from wtforms.validators import InputRequired + +from CTFd.forms import BaseForm + + +class PageEditForm(BaseForm): + title = StringField( + "Title", description="This is the title shown on the navigation bar" + ) + route = StringField( + "Route", + description="This is the URL route that your page will be at (e.g. /page). You can also enter links to link to that page.", + ) + draft = BooleanField("Draft") + hidden = BooleanField("Hidden") + auth_required = BooleanField("Authentication Required") + content = TextAreaField("Content") + format = SelectField( + "Format", + choices=[("markdown", "Markdown"), ("html", "HTML")], + default="markdown", + validators=[InputRequired()], + description="The markup format used to render the page", + ) + link_target = SelectField( + "Target", + choices=[("", "Current Page"), ("_blank", "New Tab")], + default="", + validators=[], + description="Context to open page in", + ) + + +class PageFilesUploadForm(BaseForm): + file = MultipleFileField( + "Upload Files", + description="Attach multiple files using Control+Click or Cmd+Click.", + validators=[InputRequired()], + ) + type = HiddenField("Page Type", default="page", validators=[InputRequired()]) diff --git a/CTFd/forms/self.py b/CTFd/forms/self.py new file mode 100644 index 0000000..1394e3f --- /dev/null +++ b/CTFd/forms/self.py @@ -0,0 +1,61 @@ +from flask import session +from flask_babel import lazy_gettext as _l +from wtforms import PasswordField, SelectField, StringField, TextAreaField +from wtforms.fields.html5 import DateField, URLField + +from CTFd.constants.languages import SELECT_LANGUAGE_LIST +from CTFd.forms import BaseForm +from CTFd.forms.fields import SubmitField +from CTFd.forms.users import ( + attach_custom_user_fields, + attach_user_bracket_field, + build_custom_user_fields, + build_user_bracket_field, +) +from CTFd.utils.countries import SELECT_COUNTRIES_LIST +from CTFd.utils.user import get_current_user, get_current_user_attrs + + +def SettingsForm(*args, **kwargs): + class _SettingsForm(BaseForm): + name = StringField(_l("User Name")) + email = StringField(_l("Email")) + language = SelectField(_l("Language"), choices=SELECT_LANGUAGE_LIST) + password = PasswordField(_l("Password")) + confirm = PasswordField(_l("Current Password")) + affiliation = StringField(_l("Affiliation")) + website = URLField(_l("Website")) + country = SelectField(_l("Country"), choices=SELECT_COUNTRIES_LIST) + submit = SubmitField(_l("Submit")) + + @property + def extra(self): + user = get_current_user_attrs() + fields_kwargs = _SettingsForm.get_field_kwargs() + return build_custom_user_fields( + self, + include_entries=True, + fields_kwargs=fields_kwargs, + field_entries_kwargs={"user_id": session["id"]}, + ) + build_user_bracket_field(self, value=user.bracket_id) + + @staticmethod + def get_field_kwargs(): + user = get_current_user() + field_kwargs = {"editable": True} + if user.filled_all_required_fields is False: + # Show all fields + field_kwargs = {} + return field_kwargs + + field_kwargs = _SettingsForm.get_field_kwargs() + attach_custom_user_fields(_SettingsForm, **field_kwargs) + attach_user_bracket_field(_SettingsForm) + + return _SettingsForm(*args, **kwargs) + + +class TokensForm(BaseForm): + expiration = DateField(_l("Expiration")) + description = TextAreaField("Usage Description") + submit = SubmitField(_l("Generate")) diff --git a/CTFd/forms/setup.py b/CTFd/forms/setup.py new file mode 100644 index 0000000..7a0d1f2 --- /dev/null +++ b/CTFd/forms/setup.py @@ -0,0 +1,157 @@ +from flask_babel import lazy_gettext as _l +from wtforms import ( + FileField, + HiddenField, + IntegerField, + PasswordField, + RadioField, + SelectField, + StringField, + TextAreaField, +) +from wtforms.fields.html5 import EmailField +from wtforms.validators import InputRequired +from wtforms.widgets.html5 import NumberInput + +from CTFd.constants.config import ( + AccountVisibilityTypes, + ChallengeVisibilityTypes, + RegistrationVisibilityTypes, + ScoreVisibilityTypes, +) +from CTFd.constants.themes import DEFAULT_THEME +from CTFd.forms import BaseForm +from CTFd.forms.fields import SubmitField +from CTFd.utils.config import get_themes + + +class SetupForm(BaseForm): + ctf_name = StringField( + _l("Event Name"), description=_l("The name of your CTF event/workshop") + ) + ctf_description = TextAreaField( + _l("Event Description"), description=_l("Description for the CTF") + ) + user_mode = RadioField( + _l("User Mode"), + choices=[("teams", _l("Team Mode")), ("users", _l("User Mode"))], + default="teams", + description=_l( + "Controls whether users join together in teams to play (Team Mode) or play as themselves (User Mode)" + ), + validators=[InputRequired()], + ) + + name = StringField( + _l("Admin Username"), + description=_l("Your username for the administration account"), + validators=[InputRequired()], + ) + email = EmailField( + _l("Admin Email"), + description=_l("Your email address for the administration account"), + validators=[InputRequired()], + ) + password = PasswordField( + _l("Admin Password"), + description=_l("Your password for the administration account"), + validators=[InputRequired()], + ) + + ctf_logo = FileField( + _l("Logo"), + description=_l( + "Logo to use for the website instead of a CTF name. Used as the home page button. Optional." + ), + ) + ctf_banner = FileField( + _l("Banner"), description=_l("Banner to use for the homepage. Optional.") + ) + ctf_small_icon = FileField( + _l("Small Icon"), + description=_l( + "favicon used in user's browsers. Only PNGs accepted. Must be 32x32px. Optional." + ), + ) + ctf_theme = SelectField( + _l("Theme"), + description=_l("CTFd Theme to use. Can be changed later."), + choices=list(zip(get_themes(), get_themes())), + default=DEFAULT_THEME, + validators=[InputRequired()], + ) + theme_color = HiddenField( + _l("Theme Color"), + description=_l( + "Color used by theme to control aesthetics. Requires theme support. Optional." + ), + ) + + verify_emails = SelectField( + _l("Verify Emails"), + description="Control whether users must confirm their email addresses before participating", + choices=[("true", "Enabled"), ("false", "Disabled")], + default="false", + ) + team_size = IntegerField( + widget=NumberInput(min=0), + description="Amount of users per team (Teams mode only) Optional.", + ) + challenge_visibility = SelectField( + "Challenge Visibility", + description="Control whether users must be logged in to see challenges", + choices=[ + (ChallengeVisibilityTypes.PUBLIC, "Public"), + (ChallengeVisibilityTypes.PRIVATE, "Private"), + (ChallengeVisibilityTypes.ADMINS, "Admins Only"), + ], + default=ChallengeVisibilityTypes.PRIVATE, + ) + account_visibility = SelectField( + "Account Visibility", + description="Control whether accounts (users & teams) are shown to everyone, only to authenticated users, or only to admins", + choices=[ + (AccountVisibilityTypes.PUBLIC, "Public"), + (AccountVisibilityTypes.PRIVATE, "Private"), + (AccountVisibilityTypes.ADMINS, "Admins Only"), + ], + default=AccountVisibilityTypes.PUBLIC, + ) + score_visibility = SelectField( + "Score Visibility", + description="Control whether solves/score are shown to the public, to logged in users, hidden to all non-admins, or only shown to admins", + choices=[ + (ScoreVisibilityTypes.PUBLIC, "Public"), + (ScoreVisibilityTypes.PRIVATE, "Private"), + (ScoreVisibilityTypes.HIDDEN, "Hidden"), + (ScoreVisibilityTypes.ADMINS, "Admins Only"), + ], + default=AccountVisibilityTypes.PUBLIC, + ) + registration_visibility = SelectField( + "Registration Visibility", + description="Control whether registration is enabled for everyone or disabled", + choices=[ + (RegistrationVisibilityTypes.PUBLIC, "Public"), + (RegistrationVisibilityTypes.PRIVATE, "Private"), + (RegistrationVisibilityTypes.MLC, "MajorLeagueCyber Only"), + ], + default=RegistrationVisibilityTypes.PUBLIC, + ) + + start = StringField( + _l("Start Time"), + description=_l("Time when your CTF is scheduled to start. Optional."), + ) + end = StringField( + _l("End Time"), + description=_l("Time when your CTF is scheduled to end. Optional."), + ) + + social_shares = SelectField( + _l("Social Shares"), + description="Control whether users can share links commemorating their challenge solves", + choices=[("true", "Enabled"), ("false", "Disabled")], + default="true", + ) + submit = SubmitField(_l("Finish")) diff --git a/CTFd/forms/submissions.py b/CTFd/forms/submissions.py new file mode 100644 index 0000000..5b8faeb --- /dev/null +++ b/CTFd/forms/submissions.py @@ -0,0 +1,22 @@ +from wtforms import SelectField, StringField +from wtforms.validators import InputRequired + +from CTFd.forms import BaseForm +from CTFd.forms.fields import SubmitField + + +class SubmissionSearchForm(BaseForm): + field = SelectField( + "Search Field", + choices=[ + ("provided", "Provided"), + ("id", "ID"), + ("account_id", "Account ID"), + ("challenge_id", "Challenge ID"), + ("challenge_name", "Challenge Name"), + ], + default="provided", + validators=[InputRequired()], + ) + q = StringField("Parameter", validators=[InputRequired()]) + submit = SubmitField("Search") diff --git a/CTFd/forms/teams.py b/CTFd/forms/teams.py new file mode 100644 index 0000000..3d3a255 --- /dev/null +++ b/CTFd/forms/teams.py @@ -0,0 +1,289 @@ +from flask_babel import lazy_gettext as _l +from wtforms import BooleanField, PasswordField, SelectField, StringField +from wtforms.fields.html5 import EmailField, URLField +from wtforms.validators import InputRequired + +from CTFd.forms import BaseForm +from CTFd.forms.fields import SubmitField +from CTFd.models import Brackets, TeamFieldEntries, TeamFields +from CTFd.utils.countries import SELECT_COUNTRIES_LIST +from CTFd.utils.user import get_current_team + + +def build_team_bracket_field(form_cls, value=None): + field = getattr(form_cls, "bracket_id", None) # noqa B009 + if field: + field.field_type = "select" + field.process_data(value) + return [field] + else: + return [] + + +def attach_team_bracket_field(form_cls): + brackets = Brackets.query.filter_by(type="teams").all() + if brackets: + choices = [("", "")] + [ + (bracket.id, f"{bracket.name} - {bracket.description}") + for bracket in brackets + ] + select_field = SelectField( + "Bracket", + description="Competition bracket for your team", + choices=choices, + validators=[InputRequired()], + ) + setattr(form_cls, "bracket_id", select_field) # noqa B010 + + +def build_custom_team_fields( + form_cls, + include_entries=False, + fields_kwargs=None, + field_entries_kwargs=None, + blacklisted_items=(), +): + if fields_kwargs is None: + fields_kwargs = {} + if field_entries_kwargs is None: + field_entries_kwargs = {} + + fields = [] + new_fields = TeamFields.query.filter_by(**fields_kwargs).all() + user_fields = {} + + # Only include preexisting values if asked + if include_entries is True: + for f in TeamFieldEntries.query.filter_by(**field_entries_kwargs).all(): + user_fields[f.field_id] = f.value + + for field in new_fields: + if field.name.lower() in blacklisted_items: + continue + + form_field = getattr(form_cls, f"fields[{field.id}]") + + # Add the field_type to the field so we know how to render it + form_field.field_type = field.field_type + + # Only include preexisting values if asked + if include_entries is True: + initial = user_fields.get(field.id, "") + form_field.data = initial + if form_field.render_kw: + form_field.render_kw["data-initial"] = initial + else: + form_field.render_kw = {"data-initial": initial} + + fields.append(form_field) + return fields + + +def attach_custom_team_fields(form_cls, **kwargs): + new_fields = TeamFields.query.filter_by(**kwargs).all() + for field in new_fields: + validators = [] + if field.required: + validators.append(InputRequired()) + + if field.field_type == "text": + input_field = StringField( + field.name, description=field.description, validators=validators + ) + elif field.field_type == "boolean": + input_field = BooleanField( + field.name, description=field.description, validators=validators + ) + + setattr(form_cls, f"fields[{field.id}]", input_field) + + +class TeamJoinForm(BaseForm): + name = StringField(_l("Team Name"), validators=[InputRequired()]) + password = PasswordField(_l("Team Password"), validators=[InputRequired()]) + submit = SubmitField(_l("Join")) + + +def TeamRegisterForm(*args, **kwargs): + class _TeamRegisterForm(BaseForm): + name = StringField(_l("Team Name"), validators=[InputRequired()]) + password = PasswordField(_l("Team Password"), validators=[InputRequired()]) + submit = SubmitField(_l("Create")) + + @property + def extra(self): + return build_custom_team_fields( + self, include_entries=False, blacklisted_items=() + ) + build_team_bracket_field(self) + + attach_custom_team_fields(_TeamRegisterForm) + attach_team_bracket_field(_TeamRegisterForm) + return _TeamRegisterForm(*args, **kwargs) + + +def TeamSettingsForm(*args, **kwargs): + class _TeamSettingsForm(BaseForm): + name = StringField( + _l("Team Name"), + description=_l("Your team's public name shown to other competitors"), + ) + password = PasswordField( + _l("New Team Password"), description=_l("Set a new team join password") + ) + confirm = PasswordField( + _l("Confirm Current Team Password"), + description=_l( + "Provide your current team password (or your password) to update your team's password" + ), + ) + affiliation = StringField( + _l("Affiliation"), + description=_l( + "Your team's affiliation publicly shown to other competitors" + ), + ) + website = URLField( + _l("Website"), + description=_l("Your team's website publicly shown to other competitors"), + ) + country = SelectField( + _l("Country"), + choices=SELECT_COUNTRIES_LIST, + description=_l("Your team's country publicly shown to other competitors"), + ) + submit = SubmitField(_l("Submit")) + + @property + def extra(self): + fields_kwargs = _TeamSettingsForm.get_field_kwargs() + return build_custom_team_fields( + self, + include_entries=True, + fields_kwargs=fields_kwargs, + field_entries_kwargs={"team_id": self.obj.id}, + ) + + def get_field_kwargs(): + team = get_current_team() + field_kwargs = {"editable": True} + if team.filled_all_required_fields is False: + # Show all fields + field_kwargs = {} + return field_kwargs + + def __init__(self, *args, **kwargs): + """ + Custom init to persist the obj parameter to the rest of the form + """ + super().__init__(*args, **kwargs) + obj = kwargs.get("obj") + if obj: + self.obj = obj + + field_kwargs = _TeamSettingsForm.get_field_kwargs() + attach_custom_team_fields(_TeamSettingsForm, **field_kwargs) + + return _TeamSettingsForm(*args, **kwargs) + + +class TeamCaptainForm(BaseForm): + # Choices are populated dynamically at form creation time + captain_id = SelectField( + _l("Team Captain"), choices=[], validators=[InputRequired()] + ) + submit = SubmitField("Submit") + + +class TeamSearchForm(BaseForm): + field = SelectField( + "Search Field", + choices=[ + ("name", "Name"), + ("id", "ID"), + ("affiliation", "Affiliation"), + ("website", "Website"), + ], + default="name", + validators=[InputRequired()], + ) + q = StringField("Parameter", validators=[InputRequired()]) + submit = SubmitField("Search") + + +class PublicTeamSearchForm(BaseForm): + field = SelectField( + _l("Search Field"), + choices=[ + ("name", _l("Name")), + ("affiliation", _l("Affiliation")), + ("website", _l("Website")), + ], + default="name", + validators=[InputRequired()], + ) + q = StringField(_l("Parameter"), validators=[InputRequired()]) + submit = SubmitField(_l("Search")) + + +class TeamBaseForm(BaseForm): + name = StringField(_l("Team Name"), validators=[InputRequired()]) + email = EmailField(_l("Email")) + password = PasswordField(_l("Password")) + website = URLField(_l("Website")) + affiliation = StringField(_l("Affiliation")) + country = SelectField(_l("Country"), choices=SELECT_COUNTRIES_LIST) + hidden = BooleanField(_l("Hidden")) + banned = BooleanField(_l("Banned")) + submit = SubmitField(_l("Submit")) + + +def TeamCreateForm(*args, **kwargs): + class _TeamCreateForm(TeamBaseForm): + pass + + @property + def extra(self): + return build_custom_team_fields( + self, include_entries=False + ) + build_team_bracket_field(self) + + attach_custom_team_fields(_TeamCreateForm) + attach_team_bracket_field(_TeamCreateForm) + + return _TeamCreateForm(*args, **kwargs) + + +def TeamEditForm(*args, **kwargs): + class _TeamEditForm(TeamBaseForm): + pass + + @property + def extra(self): + return build_custom_team_fields( + self, + include_entries=True, + fields_kwargs=None, + field_entries_kwargs={"team_id": self.obj.id}, + ) + build_team_bracket_field(self, value=self.obj.bracket_id) + + def __init__(self, *args, **kwargs): + """ + Custom init to persist the obj parameter to the rest of the form + """ + super().__init__(*args, **kwargs) + obj = kwargs.get("obj") + if obj: + self.obj = obj + + attach_custom_team_fields(_TeamEditForm) + attach_team_bracket_field(_TeamEditForm) + + return _TeamEditForm(*args, **kwargs) + + +class TeamInviteForm(BaseForm): + link = URLField(_l("Invite Link")) + + +class TeamInviteJoinForm(BaseForm): + submit = SubmitField(_l("Join")) diff --git a/CTFd/forms/users.py b/CTFd/forms/users.py new file mode 100644 index 0000000..ab47d7e --- /dev/null +++ b/CTFd/forms/users.py @@ -0,0 +1,235 @@ +from flask_babel import lazy_gettext as _l +from wtforms import BooleanField, PasswordField, SelectField, StringField +from wtforms.fields.html5 import EmailField +from wtforms.validators import InputRequired + +from CTFd.constants.config import Configs +from CTFd.constants.languages import SELECT_LANGUAGE_LIST +from CTFd.forms import BaseForm +from CTFd.forms.fields import SubmitField +from CTFd.models import Brackets, UserFieldEntries, UserFields +from CTFd.utils.countries import SELECT_COUNTRIES_LIST + + +def build_custom_user_fields( + form_cls, + include_entries=False, + fields_kwargs=None, + field_entries_kwargs=None, + blacklisted_items=(), +): + """ + Function used to reinject values back into forms for accessing by themes + """ + if fields_kwargs is None: + fields_kwargs = {} + if field_entries_kwargs is None: + field_entries_kwargs = {} + + fields = [] + new_fields = UserFields.query.filter_by(**fields_kwargs).all() + user_fields = {} + + # Only include preexisting values if asked + if include_entries is True: + for f in UserFieldEntries.query.filter_by(**field_entries_kwargs).all(): + user_fields[f.field_id] = f.value + + for field in new_fields: + if field.name.lower() in blacklisted_items: + continue + + form_field = getattr(form_cls, f"fields[{field.id}]") + + # Add the field_type to the field so we know how to render it + form_field.field_type = field.field_type + + # Only include preexisting values if asked + if include_entries is True: + initial = user_fields.get(field.id, "") + form_field.data = initial + if form_field.render_kw: + form_field.render_kw["data-initial"] = initial + else: + form_field.render_kw = {"data-initial": initial} + + fields.append(form_field) + return fields + + +def attach_custom_user_fields(form_cls, **kwargs): + """ + Function used to attach form fields to wtforms. + Not really a great solution but is approved by wtforms. + + https://wtforms.readthedocs.io/en/2.3.x/specific_problems/#dynamic-form-composition + """ + new_fields = UserFields.query.filter_by(**kwargs).all() + for field in new_fields: + validators = [] + if field.required: + validators.append(InputRequired()) + + if field.field_type == "text": + input_field = StringField( + field.name, description=field.description, validators=validators + ) + elif field.field_type == "boolean": + input_field = BooleanField( + field.name, description=field.description, validators=validators + ) + + setattr(form_cls, f"fields[{field.id}]", input_field) + + +def build_registration_code_field(form_cls): + """ + Build the appropriate field so we can render it via the extra property. + Add field_type so Jinja knows how to render it. + """ + if Configs.registration_code: + field = getattr(form_cls, "registration_code", None) # noqa B009 + field.field_type = "text" + return [field] + else: + return [] + + +def attach_registration_code_field(form_cls): + """ + If we have a registration code required, we attach it to the form similar + to attach_custom_user_fields + """ + if Configs.registration_code: + setattr( # noqa B010 + form_cls, + "registration_code", + StringField( + "Registration Code", + description="Registration code required to create account", + validators=[InputRequired()], + ), + ) + + +def build_user_bracket_field(form_cls, value=None): + field = getattr(form_cls, "bracket_id", None) # noqa B009 + if field: + field.field_type = "select" + field.process_data(value) + return [field] + else: + return [] + + +def attach_user_bracket_field(form_cls): + brackets = Brackets.query.filter_by(type="users").all() + if brackets: + choices = [("", "")] + [ + (bracket.id, f"{bracket.name} - {bracket.description}") + for bracket in brackets + ] + select_field = SelectField( + _l("Bracket"), + description=_l("Competition bracket for your user"), + choices=choices, + validators=[InputRequired()], + ) + setattr(form_cls, "bracket_id", select_field) # noqa B010 + + +class UserSearchForm(BaseForm): + field = SelectField( + "Search Field", + choices=[ + ("name", "Name"), + ("id", "ID"), + ("email", "Email"), + ("affiliation", "Affiliation"), + ("website", "Website"), + ("ip", "IP Address"), + ], + default="name", + validators=[InputRequired()], + ) + q = StringField("Parameter", validators=[InputRequired()]) + submit = SubmitField("Search") + + +class PublicUserSearchForm(BaseForm): + field = SelectField( + _l("Search Field"), + choices=[ + ("name", _l("Name")), + ("affiliation", _l("Affiliation")), + ("website", _l("Website")), + ], + default="name", + validators=[InputRequired()], + ) + q = StringField( + _l("Parameter"), + description=_l("Search for matching users"), + validators=[InputRequired()], + ) + submit = SubmitField(_l("Search")) + + +class UserBaseForm(BaseForm): + name = StringField("User Name", validators=[InputRequired()]) + email = EmailField("Email", validators=[InputRequired()]) + language = SelectField(_l("Language"), choices=SELECT_LANGUAGE_LIST) + password = PasswordField("Password") + website = StringField("Website") + affiliation = StringField("Affiliation") + country = SelectField("Country", choices=SELECT_COUNTRIES_LIST) + type = SelectField("Type", choices=[("user", "User"), ("admin", "Admin")]) + verified = BooleanField("Verified") + hidden = BooleanField("Hidden") + banned = BooleanField("Banned") + change_password = BooleanField("Require password change on next login") + submit = SubmitField("Submit") + + +def UserEditForm(*args, **kwargs): + class _UserEditForm(UserBaseForm): + pass + + @property + def extra(self): + return build_custom_user_fields( + self, + include_entries=True, + fields_kwargs=None, + field_entries_kwargs={"user_id": self.obj.id}, + ) + build_user_bracket_field(self, value=self.obj.bracket_id) + + def __init__(self, *args, **kwargs): + """ + Custom init to persist the obj parameter to the rest of the form + """ + super().__init__(*args, **kwargs) + obj = kwargs.get("obj") + if obj: + self.obj = obj + + attach_custom_user_fields(_UserEditForm) + attach_user_bracket_field(_UserEditForm) + + return _UserEditForm(*args, **kwargs) + + +def UserCreateForm(*args, **kwargs): + class _UserCreateForm(UserBaseForm): + notify = BooleanField("Email account credentials to user", default=True) + + @property + def extra(self): + return build_custom_user_fields( + self, include_entries=False + ) + build_user_bracket_field(self) + + attach_custom_user_fields(_UserCreateForm) + attach_user_bracket_field(_UserCreateForm) + + return _UserCreateForm(*args, **kwargs) diff --git a/CTFd/logs/.gitkeep b/CTFd/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/CTFd/models/__init__.py b/CTFd/models/__init__.py new file mode 100644 index 0000000..8ea3c1b --- /dev/null +++ b/CTFd/models/__init__.py @@ -0,0 +1,1195 @@ +import datetime +from collections import defaultdict + +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 + +from CTFd.cache import cache + +db = SQLAlchemy() +ma = Marshmallow() + + +def get_class_by_tablename(tablename): + """Return class reference mapped to table. + https://stackoverflow.com/a/66666783 + + :param tablename: String with name of table. + :return: Class reference or None. + """ + classes = [] + for m in db.Model.registry.mappers: + c = m.class_ + if hasattr(c, "__tablename__") and c.__tablename__ == tablename: + classes.append(c) + + # We didn't find this class + if len(classes) == 0: + return None + # This is a class where we have only one possible candidate. + # It's either a top level class or a polymorphic class with a specific hardcoded table name + elif len(classes) == 1: + return classes[0] + # In this case we are dealing with a polymorphic table where all of the tables have the same table name. + # However for us to identify the parent class we can look for the class that defines the polymorphic_on arg + else: + for c in classes: + mapper_args = dict(c.__mapper_args__) + if mapper_args.get("polymorphic_on") is not None: + return c + + +@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) + title = db.Column(db.Text) + content = db.Column(db.Text) + date = db.Column(db.DateTime, default=datetime.datetime.utcnow) + user_id = db.Column(db.Integer, db.ForeignKey("users.id")) + team_id = db.Column(db.Integer, db.ForeignKey("teams.id")) + + user = db.relationship("Users", foreign_keys="Notifications.user_id", lazy="select") + team = db.relationship("Teams", foreign_keys="Notifications.team_id", lazy="select") + + @property + def html(self): + from CTFd.utils.config.pages import build_markdown + from CTFd.utils.helpers import markup + + return markup(build_markdown(self.content)) + + def __init__(self, *args, **kwargs): + super(Notifications, self).__init__(**kwargs) + + +class Pages(db.Model): + __tablename__ = "pages" + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(80)) + route = db.Column(db.String(128), unique=True) + content = db.Column(db.Text) + draft = db.Column(db.Boolean) + hidden = db.Column(db.Boolean) + auth_required = db.Column(db.Boolean) + format = db.Column(db.String(80), default="markdown") + link_target = db.Column(db.String(80), nullable=True) + + files = db.relationship("PageFiles", backref="page") + + @property + def html(self): + from CTFd.utils.config.pages import build_html, build_markdown + + if self.format == "markdown": + return build_markdown(self.content) + elif self.format == "html": + return build_html(self.content) + else: + return build_markdown(self.content) + + def __init__(self, *args, **kwargs): + super(Pages, self).__init__(**kwargs) + + def __repr__(self): + return "".format(self.route) + + +class Challenges(db.Model): + __tablename__ = "challenges" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80)) + description = db.Column(db.Text) + attribution = db.Column(db.Text) + connection_info = db.Column(db.Text) + next_id = db.Column(db.Integer, db.ForeignKey("challenges.id", ondelete="SET NULL")) + max_attempts = db.Column(db.Integer, default=0) + value = db.Column(db.Integer) + category = db.Column(db.String(80)) + type = db.Column(db.String(80)) + state = db.Column(db.String(80), nullable=False, default="visible") + logic = db.Column(db.String(80), nullable=False, default="any") + initial = db.Column(db.Integer, nullable=True) + minimum = db.Column(db.Integer, nullable=True) + decay = db.Column(db.Integer, nullable=True) + function = db.Column(db.String(32), default="static") + + requirements = db.Column(db.JSON) + + files = db.relationship("ChallengeFiles", backref="challenge") + tags = db.relationship("Tags", backref="challenge") + hints = db.relationship("Hints", backref="challenge") + flags = db.relationship("Flags", backref="challenge") + comments = db.relationship("ChallengeComments", backref="challenge") + topics = db.relationship("ChallengeTopics", backref="challenge") + solution = db.relationship("Solutions", backref="challenge", uselist=False) + ratings = db.relationship("Ratings", backref="challenge") + + class alt_defaultdict(defaultdict): + """ + This slightly modified defaultdict is intended to allow SQLAlchemy to + not fail when querying Challenges that contain a missing challenge type. + + e.g. Challenges.query.all() should not fail if `type` is `a_missing_type` + """ + + def __missing__(self, key): + return self["standard"] + + __mapper_args__ = { + "polymorphic_identity": "standard", + "polymorphic_on": type, + "_polymorphic_map": alt_defaultdict(), + } + + @property + def byline(self): + from CTFd.utils.config.pages import build_markdown + from CTFd.utils.helpers import markup + + return markup(build_markdown(self.attribution)) + + @property + def html(self): + from CTFd.utils.config.pages import build_markdown + from CTFd.utils.helpers import markup + + return markup(build_markdown(self.description)) + + @property + def solution_id(self): + if self.solution: + return self.solution.id + return None + + @property + def plugin_class(self): + from CTFd.plugins.challenges import get_chal_class + + return get_chal_class(self.type) + + def __init__(self, *args, **kwargs): + super(Challenges, self).__init__(**kwargs) + + def __repr__(self): + return "" % self.name + + +class Hints(db.Model): + __tablename__ = "hints" + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(80)) + type = db.Column(db.String(80), default="standard") + challenge_id = db.Column( + db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE") + ) + content = db.Column(db.Text) + cost = db.Column(db.Integer, default=0) + requirements = db.Column(db.JSON) + + __mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type} + + @property + def name(self): + return "Hint {id}".format(id=self.id) + + @property + def category(self): + return self.__tablename__ + + @property + def description(self): + return "Hint for {name}".format(name=self.challenge.name) + + @property + def html(self): + from CTFd.utils.config.pages import build_markdown + from CTFd.utils.helpers import markup + + return markup(build_markdown(self.content)) + + @property + def prerequisites(self): + if self.requirements: + return self.requirements.get("prerequisites", []) + return [] + + def __init__(self, *args, **kwargs): + super(Hints, self).__init__(**kwargs) + + def __repr__(self): + return "" % self.content + + +class Awards(db.Model): + __tablename__ = "awards" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) + team_id = db.Column(db.Integer, db.ForeignKey("teams.id", ondelete="CASCADE")) + type = db.Column(db.String(80), default="standard") + name = db.Column(db.String(80)) + description = db.Column(db.Text) + date = db.Column(db.DateTime, default=datetime.datetime.utcnow) + value = db.Column(db.Integer) + category = db.Column(db.String(80)) + icon = db.Column(db.Text) + requirements = db.Column(db.JSON) + + user = db.relationship("Users", foreign_keys="Awards.user_id", lazy="select") + team = db.relationship("Teams", foreign_keys="Awards.team_id", lazy="select") + + __mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type} + + @hybrid_property + def account_id(self): + from CTFd.utils import get_config + + user_mode = get_config("user_mode") + if user_mode == "teams": + return self.team_id + elif user_mode == "users": + return self.user_id + + def __init__(self, *args, **kwargs): + super(Awards, self).__init__(**kwargs) + + def __repr__(self): + return "" % self.name + + +class Tags(db.Model): + __tablename__ = "tags" + id = db.Column(db.Integer, primary_key=True) + challenge_id = db.Column( + db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE") + ) + value = db.Column(db.String(80)) + + def __init__(self, *args, **kwargs): + super(Tags, self).__init__(**kwargs) + + +class Topics(db.Model): + __tablename__ = "topics" + id = db.Column(db.Integer, primary_key=True) + value = db.Column(db.String(255), unique=True) + + def __init__(self, *args, **kwargs): + super(Topics, self).__init__(**kwargs) + + +class ChallengeTopics(db.Model): + __tablename__ = "challenge_topics" + id = db.Column(db.Integer, primary_key=True) + challenge_id = db.Column( + db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE") + ) + topic_id = db.Column(db.Integer, db.ForeignKey("topics.id", ondelete="CASCADE")) + + topic = db.relationship( + "Topics", foreign_keys="ChallengeTopics.topic_id", lazy="select" + ) + + def __init__(self, *args, **kwargs): + super(ChallengeTopics, self).__init__(**kwargs) + + +class Solutions(db.Model): + __tablename__ = "solutions" + id = db.Column(db.Integer, primary_key=True) + challenge_id = db.Column( + db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE"), unique=True + ) + content = db.Column(db.Text) + state = db.Column(db.String(80), nullable=False, default="hidden") + + @property + def html(self): + from CTFd.utils.config.pages import build_markdown + from CTFd.utils.helpers import markup + + return markup(build_markdown(self.content)) + + def __init__(self, *args, **kwargs): + super(Solutions, self).__init__(**kwargs) + + def __repr__(self): + return "" % self.id + + +class Files(db.Model): + __tablename__ = "files" + id = db.Column(db.Integer, primary_key=True) + type = db.Column(db.String(80), default="standard") + location = db.Column(db.Text) + sha1sum = db.Column(db.String(40)) + + __mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type} + + def __init__(self, *args, **kwargs): + super(Files, self).__init__(**kwargs) + + def __repr__(self): + return "".format( + type=self.type, location=self.location + ) + + +class ChallengeFiles(Files): + __mapper_args__ = {"polymorphic_identity": "challenge"} + challenge_id = db.Column( + db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE") + ) + + def __init__(self, *args, **kwargs): + super(ChallengeFiles, self).__init__(**kwargs) + + +class PageFiles(Files): + __mapper_args__ = {"polymorphic_identity": "page"} + page_id = db.Column(db.Integer, db.ForeignKey("pages.id")) + + def __init__(self, *args, **kwargs): + super(PageFiles, self).__init__(**kwargs) + + +class SolutionFiles(Files): + __mapper_args__ = {"polymorphic_identity": "solution"} + solution_id = db.Column(db.Integer, db.ForeignKey("solutions.id")) + + +class Flags(db.Model): + __tablename__ = "flags" + id = db.Column(db.Integer, primary_key=True) + challenge_id = db.Column( + db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE") + ) + type = db.Column(db.String(80)) + content = db.Column(db.Text) + data = db.Column(db.Text) + + __mapper_args__ = {"polymorphic_on": type} + + def __init__(self, *args, **kwargs): + super(Flags, self).__init__(**kwargs) + + def __repr__(self): + return "".format(self.content, self.challenge_id) + + +class Users(db.Model): + __tablename__ = "users" + __table_args__ = (db.UniqueConstraint("id", "oauth_id"), {}) + # Core attributes + id = db.Column(db.Integer, primary_key=True) + oauth_id = db.Column(db.Integer, unique=True) + # User names are not constrained to be unique to allow for official/unofficial teams. + name = db.Column(db.String(128)) + password = db.Column(db.String(128)) + email = db.Column(db.String(128), unique=True) + type = db.Column(db.String(80)) + secret = db.Column(db.String(128)) + + # Supplementary attributes + website = db.Column(db.String(128)) + affiliation = db.Column(db.String(128)) + country = db.Column(db.String(32)) + bracket_id = db.Column( + db.Integer, db.ForeignKey("brackets.id", ondelete="SET NULL") + ) + hidden = db.Column(db.Boolean, default=False) + banned = db.Column(db.Boolean, default=False) + verified = db.Column(db.Boolean, default=False) + language = db.Column(db.String(32), nullable=True, default=None) + change_password = db.Column(db.Boolean, default=False) + + # Relationship for Teams + team_id = db.Column(db.Integer, db.ForeignKey("teams.id")) + + # Relationship for Brackets + bracket = db.relationship("Brackets", foreign_keys=[bracket_id], lazy="joined") + + field_entries = db.relationship( + "UserFieldEntries", + foreign_keys="UserFieldEntries.user_id", + lazy="joined", + back_populates="user", + ) + + created = db.Column(db.DateTime, default=datetime.datetime.utcnow) + + __mapper_args__ = {"polymorphic_identity": "user", "polymorphic_on": type} + + def __init__(self, **kwargs): + super(Users, self).__init__(**kwargs) + + @validates("password") + def validate_password(self, key, plaintext): + from CTFd.utils.crypto import hash_password + + return hash_password(str(plaintext)) + + @hybrid_property + def account_id(self): + from CTFd.utils import get_config + + user_mode = get_config("user_mode") + if user_mode == "teams": + return self.team_id + elif user_mode == "users": + return self.id + + @hybrid_property + def account(self): + from CTFd.utils import get_config + + user_mode = get_config("user_mode") + if user_mode == "teams": + return self.team + elif user_mode == "users": + return self + + @property + def fields(self): + return self.get_fields(admin=False) + + @property + def solves(self): + return self.get_solves(admin=False) + + @property + def fails(self): + return self.get_fails(admin=False) + + @property + def awards(self): + return self.get_awards(admin=False) + + @property + def score(self): + from CTFd.utils.config.visibility import scores_visible + + if scores_visible(): + return self.get_score(admin=False) + else: + return None + + @property + def place(self): + from CTFd.utils.config.visibility import scores_visible + + if scores_visible(): + return self.get_place(admin=False) + else: + return None + + @property + def filled_all_required_fields(self): + required_user_fields = { + u.id + for u in UserFields.query.with_entities(UserFields.id) + .filter_by(required=True) + .all() + } + submitted_user_fields = { + u.field_id + for u in UserFieldEntries.query.with_entities(UserFieldEntries.field_id) + .filter_by(user_id=self.id) + .all() + } + # Require that users select a bracket + missing_bracket = ( + Brackets.query.filter_by(type="users").count() + and self.bracket_id is not None + ) + return required_user_fields.issubset(submitted_user_fields) and missing_bracket + + def get_fields(self, admin=False): + if admin: + return self.field_entries + + return [ + entry for entry in self.field_entries if entry.field.public and entry.value + ] + + def get_solves(self, admin=False): + from CTFd.utils import get_config + + 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) + solves = solves.filter(Solves.date < dt) + return solves.all() + + def get_fails(self, admin=False): + from CTFd.utils import get_config + + 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) + fails = fails.filter(Fails.date < dt) + return fails.all() + + def get_awards(self, admin=False): + from CTFd.utils import get_config + + 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) + awards = awards.filter(Awards.date < dt) + return awards.all() + + @cache.memoize() + def get_score(self, admin=False): + score = db.func.sum(Challenges.value).label("score") + user = ( + db.session.query(Solves.user_id, score) + .join(Users, Solves.user_id == Users.id) + .join(Challenges, Solves.challenge_id == Challenges.id) + .filter(Users.id == self.id) + ) + + award_score = db.func.sum(Awards.value).label("award_score") + award = db.session.query(award_score).filter_by(user_id=self.id) + + if not admin: + freeze = Configs.query.filter_by(key="freeze").first() + if freeze and freeze.value: + freeze = int(freeze.value) + freeze = datetime.datetime.utcfromtimestamp(freeze) + user = user.filter(Solves.date < freeze) + award = award.filter(Awards.date < freeze) + + user = user.group_by(Solves.user_id).first() + award = award.first() + + if user and award: + return int(user.score or 0) + int(award.award_score or 0) + elif user: + return int(user.score or 0) + elif award: + return int(award.award_score or 0) + else: + return 0 + + @cache.memoize() + def get_place(self, admin=False, numeric=False): + """ + This method is generally a clone of CTFd.scoreboard.get_standings. + The point being that models.py must be self-reliant and have little + to no imports within the CTFd application as importing from the + application itself will result in a circular import. + """ + from CTFd.utils.humanize.numbers import ordinalize + from CTFd.utils.scores import get_user_standings + + standings = get_user_standings(admin=admin) + + for i, user in enumerate(standings): + if user.user_id == self.id: + n = i + 1 + if numeric: + return n + return ordinalize(n) + else: + return None + + +class Admins(Users): + __tablename__ = "admins" + __mapper_args__ = {"polymorphic_identity": "admin"} + + +class Teams(db.Model): + __tablename__ = "teams" + __table_args__ = (db.UniqueConstraint("id", "oauth_id"), {}) + # Core attributes + id = db.Column(db.Integer, primary_key=True) + oauth_id = db.Column(db.Integer, unique=True) + # Team names are not constrained to be unique to allow for official/unofficial teams. + name = db.Column(db.String(128)) + email = db.Column(db.String(128), unique=True) + password = db.Column(db.String(128)) + secret = db.Column(db.String(128)) + + members = db.relationship( + "Users", backref="team", foreign_keys="Users.team_id", lazy="joined" + ) + + # Supplementary attributes + website = db.Column(db.String(128)) + affiliation = db.Column(db.String(128)) + country = db.Column(db.String(32)) + bracket_id = db.Column( + db.Integer, db.ForeignKey("brackets.id", ondelete="SET NULL") + ) + hidden = db.Column(db.Boolean, default=False) + banned = db.Column(db.Boolean, default=False) + + # Relationship for Users + captain_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL")) + captain = db.relationship("Users", foreign_keys=[captain_id]) + + # Relationship for Brackets + bracket = db.relationship("Brackets", foreign_keys=[bracket_id], lazy="joined") + + field_entries = db.relationship( + "TeamFieldEntries", + foreign_keys="TeamFieldEntries.team_id", + lazy="joined", + back_populates="team", + ) + + created = db.Column(db.DateTime, default=datetime.datetime.utcnow) + + def __init__(self, **kwargs): + super(Teams, self).__init__(**kwargs) + + @validates("password") + def validate_password(self, key, plaintext): + from CTFd.utils.crypto import hash_password + + return hash_password(str(plaintext)) + + @property + def fields(self): + return self.get_fields(admin=False) + + @property + def solves(self): + return self.get_solves(admin=False) + + @property + def fails(self): + return self.get_fails(admin=False) + + @property + def awards(self): + return self.get_awards(admin=False) + + @property + def score(self): + from CTFd.utils.config.visibility import scores_visible + + if scores_visible(): + return self.get_score(admin=False) + else: + return None + + @property + def place(self): + from CTFd.utils.config.visibility import scores_visible + + if scores_visible(): + return self.get_place(admin=False) + else: + return None + + @property + def filled_all_required_fields(self): + required_team_fields = { + u.id + for u in TeamFields.query.with_entities(TeamFields.id) + .filter_by(required=True) + .all() + } + submitted_team_fields = { + u.field_id + for u in TeamFieldEntries.query.with_entities(TeamFieldEntries.field_id) + .filter_by(team_id=self.id) + .all() + } + missing_bracket = ( + Brackets.query.filter_by(type="teams").count() + and self.bracket_id is not None + ) + return required_team_fields.issubset(submitted_team_fields) and missing_bracket + + def get_fields(self, admin=False): + if admin: + return self.field_entries + + return [ + entry for entry in self.field_entries if entry.field.public and entry.value + ] + + def get_invite_code(self): + from flask import current_app # noqa: I001 + + from CTFd.utils.security.signing import hmac, serialize + + secret_key = current_app.config["SECRET_KEY"] + if isinstance(secret_key, str): + secret_key = secret_key.encode("utf-8") + + verification_secret = secret_key + if self.password: + team_password_key = self.password.encode("utf-8") + verification_secret += team_password_key + + invite_object = { + "id": self.id, + "v": hmac(str(self.id), secret=verification_secret), + } + code = serialize(data=invite_object, secret=secret_key) + return code + + @classmethod + def load_invite_code(cls, code): + from flask import current_app # noqa: I001 + + from CTFd.exceptions import TeamTokenExpiredException, TeamTokenInvalidException + from CTFd.utils.security.signing import ( + BadSignature, + BadTimeSignature, + hmac, + unserialize, + ) + + secret_key = current_app.config["SECRET_KEY"] + if isinstance(secret_key, str): + secret_key = secret_key.encode("utf-8") + + # Unserialize the invite code + try: + # Links expire after 1 day + invite_object = unserialize(code, max_age=86400) + except BadTimeSignature: + raise TeamTokenExpiredException + except BadSignature: + raise TeamTokenInvalidException + + # Load the team by the ID in the invite + team_id = invite_object["id"] + team = cls.query.filter_by(id=team_id).first_or_404() + + # Create the team specific secret + verification_secret = secret_key + if team.password: + team_password_key = team.password.encode("utf-8") + verification_secret += team_password_key + + # Verify the team verficiation code + verified = hmac(str(team.id), secret=verification_secret) == invite_object["v"] + if verified is False: + raise TeamTokenInvalidException + return team + + def get_solves(self, admin=False): + from CTFd.utils import get_config + + member_ids = [member.id for member in self.members] + + solves = Solves.query.filter(Solves.user_id.in_(member_ids)).order_by( + Solves.date.desc() + ) + + freeze = get_config("freeze") + if freeze and admin is False: + dt = datetime.datetime.utcfromtimestamp(freeze) + solves = solves.filter(Solves.date < dt) + + return solves.all() + + def get_fails(self, admin=False): + from CTFd.utils import get_config + + member_ids = [member.id for member in self.members] + + fails = Fails.query.filter(Fails.user_id.in_(member_ids)).order_by( + Fails.date.desc() + ) + + freeze = get_config("freeze") + if freeze and admin is False: + dt = datetime.datetime.utcfromtimestamp(freeze) + fails = fails.filter(Fails.date < dt) + + return fails.all() + + def get_awards(self, admin=False): + from CTFd.utils import get_config + + member_ids = [member.id for member in self.members] + + awards = Awards.query.filter(Awards.user_id.in_(member_ids)).order_by( + Awards.date.desc() + ) + + freeze = get_config("freeze") + if freeze and admin is False: + dt = datetime.datetime.utcfromtimestamp(freeze) + awards = awards.filter(Awards.date < dt) + + return awards.all() + + @cache.memoize() + def get_score(self, admin=False): + score = 0 + for member in self.members: + score += member.get_score(admin=admin) + return score + + @cache.memoize() + def get_place(self, admin=False, numeric=False): + """ + This method is generally a clone of CTFd.scoreboard.get_standings. + The point being that models.py must be self-reliant and have little + to no imports within the CTFd application as importing from the + application itself will result in a circular import. + """ + from CTFd.utils.humanize.numbers import ordinalize + from CTFd.utils.scores import get_team_standings # noqa: I001 + + standings = get_team_standings(admin=admin) + + for i, team in enumerate(standings): + if team.team_id == self.id: + n = i + 1 + if numeric: + return n + return ordinalize(n) + else: + return None + + +class Submissions(db.Model): + __tablename__ = "submissions" + id = db.Column(db.Integer, primary_key=True) + challenge_id = db.Column( + db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE") + ) + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) + team_id = db.Column(db.Integer, db.ForeignKey("teams.id", ondelete="CASCADE")) + ip = db.Column(db.String(46)) + provided = db.Column(db.Text) + type = db.Column(db.String(32)) + date = db.Column(db.DateTime, default=datetime.datetime.utcnow) + + # Relationships + user = db.relationship("Users", foreign_keys="Submissions.user_id", lazy="select") + team = db.relationship("Teams", foreign_keys="Submissions.team_id", lazy="select") + challenge = db.relationship( + "Challenges", foreign_keys="Submissions.challenge_id", lazy="select" + ) + + __mapper_args__ = {"polymorphic_on": type} + + @hybrid_property + def account_id(self): + from CTFd.utils import get_config + + user_mode = get_config("user_mode") + if user_mode == "teams": + return self.team_id + elif user_mode == "users": + return self.user_id + + @hybrid_property + def account(self): + from CTFd.utils import get_config + + user_mode = get_config("user_mode") + if user_mode == "teams": + return self.team + elif user_mode == "users": + return self.user + + @staticmethod + def get_child(type): + child_classes = { + x.polymorphic_identity: x.class_ + for x in Submissions.__mapper__.self_and_descendants + } + return child_classes[type] + + def __repr__(self): + return f"" + + +class Solves(Submissions): + __tablename__ = "solves" + __table_args__ = ( + db.UniqueConstraint("challenge_id", "user_id"), + db.UniqueConstraint("challenge_id", "team_id"), + {}, + ) + id = db.Column( + None, db.ForeignKey("submissions.id", ondelete="CASCADE"), primary_key=True + ) + challenge_id = column_property( + db.Column(db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE")), + Submissions.challenge_id, + ) + user_id = column_property( + db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")), + Submissions.user_id, + ) + team_id = column_property( + db.Column(db.Integer, db.ForeignKey("teams.id", ondelete="CASCADE")), + Submissions.team_id, + ) + + user = db.relationship("Users", foreign_keys="Solves.user_id", lazy="select") + team = db.relationship("Teams", foreign_keys="Solves.team_id", lazy="select") + challenge = db.relationship( + "Challenges", foreign_keys="Solves.challenge_id", lazy="select" + ) + + __mapper_args__ = {"polymorphic_identity": "correct"} + + +class Fails(Submissions): + __mapper_args__ = {"polymorphic_identity": "incorrect"} + + +class Partials(Submissions): + __mapper_args__ = {"polymorphic_identity": "partial"} + + +class Discards(Submissions): + __mapper_args__ = {"polymorphic_identity": "discard"} + + +class Ratelimiteds(Submissions): + __mapper_args__ = {"polymorphic_identity": "ratelimited"} + + +class Unlocks(db.Model): + __tablename__ = "unlocks" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) + team_id = db.Column(db.Integer, db.ForeignKey("teams.id", ondelete="CASCADE")) + target = db.Column(db.Integer) + date = db.Column(db.DateTime, default=datetime.datetime.utcnow) + type = db.Column(db.String(32)) + + __mapper_args__ = {"polymorphic_on": type} + + @hybrid_property + def account_id(self): + from CTFd.utils import get_config + + user_mode = get_config("user_mode") + if user_mode == "teams": + return self.team_id + elif user_mode == "users": + return self.user_id + + def __repr__(self): + return "" % self.id + + +class HintUnlocks(Unlocks): + __mapper_args__ = {"polymorphic_identity": "hints"} + + +class SolutionUnlocks(Unlocks): + __mapper_args__ = {"polymorphic_identity": "solutions"} + + +class Tracking(db.Model): + __tablename__ = "tracking" + id = db.Column(db.Integer, primary_key=True) + type = db.Column(db.String(32)) + ip = db.Column(db.String(46)) + target = db.Column(db.Integer, nullable=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) + date = db.Column(db.DateTime, default=datetime.datetime.utcnow) + + user = db.relationship("Users", foreign_keys="Tracking.user_id", lazy="select") + + __mapper_args__ = {"polymorphic_on": type} + + def __init__(self, *args, **kwargs): + super(Tracking, self).__init__(**kwargs) + + def __repr__(self): + return "" % self.ip + + +class Configs(db.Model): + __tablename__ = "config" + id = db.Column(db.Integer, primary_key=True) + key = db.Column(db.Text) + value = db.Column(db.Text) + + def __init__(self, *args, **kwargs): + super(Configs, self).__init__(**kwargs) + + +class Tokens(db.Model): + __tablename__ = "tokens" + id = db.Column(db.Integer, primary_key=True) + type = db.Column(db.String(32)) + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) + created = db.Column(db.DateTime, default=datetime.datetime.utcnow) + expiration = db.Column( + db.DateTime, + default=lambda: datetime.datetime.utcnow() + datetime.timedelta(days=30), + ) + description = db.Column(db.Text) + value = db.Column(db.String(128), unique=True) + + user = db.relationship("Users", foreign_keys="Tokens.user_id", lazy="select") + + __mapper_args__ = {"polymorphic_on": type} + + def __init__(self, *args, **kwargs): + super(Tokens, self).__init__(**kwargs) + + def __repr__(self): + return "" % self.id + + +class UserTokens(Tokens): + __mapper_args__ = {"polymorphic_identity": "user"} + + +class Comments(db.Model): + __tablename__ = "comments" + id = db.Column(db.Integer, primary_key=True) + type = db.Column(db.String(80), default="standard") + content = db.Column(db.Text) + date = db.Column(db.DateTime, default=datetime.datetime.utcnow) + author_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) + author = db.relationship("Users", foreign_keys="Comments.author_id", lazy="select") + + @property + def html(self): + from CTFd.utils.config.pages import build_markdown + from CTFd.utils.helpers import markup + + return markup(build_markdown(self.content, sanitize=True)) + + __mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type} + + +class ChallengeComments(Comments): + __mapper_args__ = {"polymorphic_identity": "challenge"} + challenge_id = db.Column( + db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE") + ) + + +class UserComments(Comments): + __mapper_args__ = {"polymorphic_identity": "user"} + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) + + +class TeamComments(Comments): + __mapper_args__ = {"polymorphic_identity": "team"} + team_id = db.Column(db.Integer, db.ForeignKey("teams.id", ondelete="CASCADE")) + + +class PageComments(Comments): + __mapper_args__ = {"polymorphic_identity": "page"} + page_id = db.Column(db.Integer, db.ForeignKey("pages.id", ondelete="CASCADE")) + + +class Fields(db.Model): + __tablename__ = "fields" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.Text) + type = db.Column(db.String(80), default="standard") + field_type = db.Column(db.String(80)) + description = db.Column(db.Text) + required = db.Column(db.Boolean, default=False) + public = db.Column(db.Boolean, default=False) + editable = db.Column(db.Boolean, default=False) + + __mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type} + + +class UserFields(Fields): + __mapper_args__ = {"polymorphic_identity": "user"} + + +class TeamFields(Fields): + __mapper_args__ = {"polymorphic_identity": "team"} + + +class FieldEntries(db.Model): + __tablename__ = "field_entries" + id = db.Column(db.Integer, primary_key=True) + type = db.Column(db.String(80), default="standard") + value = db.Column(db.JSON) + field_id = db.Column(db.Integer, db.ForeignKey("fields.id", ondelete="CASCADE")) + + field = db.relationship( + "Fields", foreign_keys="FieldEntries.field_id", lazy="joined" + ) + + __mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type} + + @hybrid_property + def name(self): + return self.field.name + + @hybrid_property + def description(self): + return self.field.description + + +class UserFieldEntries(FieldEntries): + __mapper_args__ = {"polymorphic_identity": "user"} + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) + user = db.relationship( + "Users", foreign_keys="UserFieldEntries.user_id", back_populates="field_entries" + ) + + +class TeamFieldEntries(FieldEntries): + __mapper_args__ = {"polymorphic_identity": "team"} + team_id = db.Column(db.Integer, db.ForeignKey("teams.id", ondelete="CASCADE")) + team = db.relationship( + "Teams", foreign_keys="TeamFieldEntries.team_id", back_populates="field_entries" + ) + + +class Brackets(db.Model): + __tablename__ = "brackets" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(255)) + description = db.Column(db.Text) + type = db.Column(db.String(80)) + + +class Ratings(db.Model): + __tablename__ = "ratings" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) + challenge_id = db.Column( + db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE") + ) + value = db.Column(db.Integer) + review = db.Column(db.String(2000), nullable=True) + date = db.Column(db.DateTime, default=datetime.datetime.utcnow) + + user = db.relationship("Users", foreign_keys="Ratings.user_id", lazy="select") + + # Ensure one rating per user per challenge + __table_args__ = (db.UniqueConstraint("user_id", "challenge_id"),) + + def __init__(self, *args, **kwargs): + super(Ratings, self).__init__(**kwargs) + + def __repr__(self): + return "".format( + self.user_id, self.challenge_id, self.value + ) diff --git a/CTFd/plugins/__init__.py b/CTFd/plugins/__init__.py new file mode 100644 index 0000000..93094dc --- /dev/null +++ b/CTFd/plugins/__init__.py @@ -0,0 +1,209 @@ +import glob +import importlib +import os +from collections import namedtuple + +from flask import current_app as app +from flask import send_file, send_from_directory, url_for + +from CTFd.utils.config.pages import get_pages +from CTFd.utils.decorators import admins_only as admins_only_wrapper +from CTFd.utils.plugins import override_template as utils_override_template +from CTFd.utils.plugins import ( + register_admin_script as utils_register_admin_plugin_script, +) +from CTFd.utils.plugins import ( + register_admin_stylesheet as utils_register_admin_plugin_stylesheet, +) +from CTFd.utils.plugins import register_script as utils_register_plugin_script +from CTFd.utils.plugins import register_stylesheet as utils_register_plugin_stylesheet + +Menu = namedtuple("Menu", ["title", "route", "link_target"]) + + +def register_plugin_assets_directory(app, base_path, admins_only=False, endpoint=None): + """ + Registers a directory to serve assets + + :param app: A CTFd application + :param string base_path: The path to the directory + :param boolean admins_only: Whether or not the assets served out of the directory should be accessible to the public + :return: + """ + base_path = base_path.strip("/") + if endpoint is None: + endpoint = base_path.replace("/", ".") + + def assets_handler(path): + return send_from_directory(base_path, path) + + rule = "/" + base_path + "/" + app.add_url_rule(rule=rule, endpoint=endpoint, view_func=assets_handler) + + +def register_plugin_asset(app, asset_path, admins_only=False, endpoint=None): + """ + Registers an file path to be served by CTFd + + :param app: A CTFd application + :param string asset_path: The path to the asset file + :param boolean admins_only: Whether or not this file should be accessible to the public + :return: + """ + asset_path = asset_path.strip("/") + if endpoint is None: + endpoint = asset_path.replace("/", ".") + + def asset_handler(): + return send_file(asset_path, max_age=3600) + + if admins_only: + asset_handler = admins_only_wrapper(asset_handler) + rule = "/" + asset_path + app.add_url_rule(rule=rule, endpoint=endpoint, view_func=asset_handler) + + +def override_template(*args, **kwargs): + """ + Overrides a template with the provided html content. + + e.g. override_template('scoreboard.html', '

scores

') + """ + utils_override_template(*args, **kwargs) + + +def register_plugin_script(*args, **kwargs): + """ + Adds a given script to the base.html template which all pages inherit from + """ + utils_register_plugin_script(*args, **kwargs) + + +def register_plugin_stylesheet(*args, **kwargs): + """ + Adds a given stylesheet to the base.html template which all pages inherit from. + """ + utils_register_plugin_stylesheet(*args, **kwargs) + + +def register_admin_plugin_script(*args, **kwargs): + """ + Adds a given script to the base.html of the admin theme which all admin pages inherit from + :param args: + :param kwargs: + :return: + """ + utils_register_admin_plugin_script(*args, **kwargs) + + +def register_admin_plugin_stylesheet(*args, **kwargs): + """ + Adds a given stylesheet to the base.html of the admin theme which all admin pages inherit from + :param args: + :param kwargs: + :return: + """ + utils_register_admin_plugin_stylesheet(*args, **kwargs) + + +def register_admin_plugin_menu_bar(title, route, link_target=None): + """ + Registers links on the Admin Panel menubar/navbar + + :param name: A string that is shown on the navbar HTML + :param route: A string that is the href used by the link + :return: + """ + am = Menu(title=title, route=route, link_target=link_target) + app.admin_plugin_menu_bar.append(am) + + +def get_admin_plugin_menu_bar(): + """ + Access the list used to store the plugin menu bar + + :return: Returns a list of Menu namedtuples. They have name, and route attributes. + """ + return app.admin_plugin_menu_bar + + +def register_user_page_menu_bar(title, route, link_target=None): + """ + Registers links on the User side menubar/navbar + + :param name: A string that is shown on the navbar HTML + :param route: A string that is the href used by the link + :return: + """ + p = Menu(title=title, route=route, link_target=link_target) + app.plugin_menu_bar.append(p) + + +def get_user_page_menu_bar(): + """ + Access the list used to store the user page menu bar + + :return: Returns a list of Menu namedtuples. They have name, and route attributes. + """ + pages = [] + for p in get_pages() + app.plugin_menu_bar: + if p.route.startswith("http"): + route = p.route + else: + route = url_for("views.static_html", route=p.route) + pages.append(Menu(title=p.title, route=route, link_target=p.link_target)) + return pages + + +def bypass_csrf_protection(f): + """ + Decorator that allows a route to bypass the need for a CSRF nonce on POST requests. + + This should be considered beta and may change in future versions. + + :param f: A function that needs to bypass CSRF protection + :return: Returns a function with the _bypass_csrf attribute set which tells CTFd to not require CSRF protection. + """ + f._bypass_csrf = True + return f + + +def get_plugin_names(): + modules = sorted(glob.glob(app.plugins_dir + "/*")) + blacklist = {"__pycache__"} + plugins = [] + for module in modules: + module_name = os.path.basename(module) + if os.path.isdir(module) and module_name not in blacklist: + plugins.append(module_name) + return plugins + + +def init_plugins(app): + """ + Searches for the load function in modules in the CTFd/plugins folder. This function is called with the current CTFd + app as a parameter. This allows CTFd plugins to modify CTFd's behavior. + + :param app: A CTFd application + :return: + """ + app.admin_plugin_scripts = [] + app.admin_plugin_stylesheets = [] + app.plugin_scripts = [] + app.plugin_stylesheets = [] + + app.admin_plugin_menu_bar = [] + app.plugin_menu_bar = [] + app.plugins_dir = os.path.dirname(__file__) + + if app.config.get("SAFE_MODE", False) is False: + for plugin in get_plugin_names(): + module = "." + plugin + module = importlib.import_module(module, package="CTFd.plugins") + module.load(app) + print(" * Loaded module, %s" % module) + else: + print("SAFE_MODE is enabled. Skipping plugin loading.") + + app.jinja_env.globals.update(get_admin_plugin_menu_bar=get_admin_plugin_menu_bar) + app.jinja_env.globals.update(get_user_page_menu_bar=get_user_page_menu_bar) diff --git a/CTFd/plugins/challenges/__init__.py b/CTFd/plugins/challenges/__init__.py new file mode 100644 index 0000000..b1d3ba5 --- /dev/null +++ b/CTFd/plugins/challenges/__init__.py @@ -0,0 +1,342 @@ +from dataclasses import dataclass + +from flask import Blueprint +from sqlalchemy.exc import IntegrityError + +from CTFd.exceptions.challenges import ( + ChallengeCreateException, + ChallengeSolveException, + ChallengeUpdateException, +) +from CTFd.models import ( + ChallengeFiles, + Challenges, + Fails, + Flags, + Hints, + Partials, + Ratelimiteds, + Solves, + Tags, + db, +) +from CTFd.plugins import register_plugin_assets_directory +from CTFd.plugins.challenges.decay import DECAY_FUNCTIONS, logarithmic +from CTFd.plugins.challenges.logic import ( + challenge_attempt_all, + challenge_attempt_any, + challenge_attempt_team, +) +from CTFd.utils.uploads import delete_file +from CTFd.utils.user import get_ip + + +@dataclass +class ChallengeResponse: + status: str + message: str + + def __iter__(self): + """Allow tuple-like unpacking for backwards compatibility.""" + # TODO: CTFd 4.0 remove this behavior as we should move away from the tuple strategy + yield (True if self.status == "correct" else False) + yield self.message + + +def calculate_value(challenge): + f = DECAY_FUNCTIONS.get(challenge.function, logarithmic) + value = f(challenge) + + challenge.value = value + db.session.commit() + return challenge + + +class BaseChallenge(object): + id = None + name = None + templates = {} + scripts = {} + challenge_model = Challenges + + @classmethod + def create(cls, request): + """ + This method is used to process the challenge creation request. + + :param request: + :return: + """ + data = request.form or request.get_json() + + challenge = cls.challenge_model(**data) + + if challenge.function in DECAY_FUNCTIONS: + if data.get("value") and not data.get("initial"): + challenge.initial = data["value"] + + for attr in ("initial", "minimum", "decay"): + db.session.rollback() + if getattr(challenge, attr) is None: + raise ChallengeCreateException( + f"Missing '{attr}' but function is {challenge.function}" + ) + + db.session.add(challenge) + db.session.commit() + + # If the challenge is dynamic we should calculate a new value + if challenge.function in DECAY_FUNCTIONS: + return calculate_value(challenge) + + return challenge + + @classmethod + def read(cls, challenge): + """ + This method is in used to access the data of a challenge in a format processable by the front end. + + :param challenge: + :return: Challenge object, data dictionary to be returned to the user + """ + data = { + "id": challenge.id, + "name": challenge.name, + "value": challenge.value, + "description": challenge.description, + "attribution": challenge.attribution, + "connection_info": challenge.connection_info, + "next_id": challenge.next_id, + "category": challenge.category, + "state": challenge.state, + "max_attempts": challenge.max_attempts, + "logic": challenge.logic, + "initial": challenge.initial if challenge.function != "static" else None, + "decay": challenge.decay if challenge.function != "static" else None, + "minimum": challenge.minimum if challenge.function != "static" else None, + "function": challenge.function, + "type": challenge.type, + "type_data": { + "id": cls.id, + "name": cls.name, + "templates": cls.templates, + "scripts": cls.scripts, + }, + } + return data + + @classmethod + def update(cls, challenge, request): + """ + This method is used to update the information associated with a challenge. This should be kept strictly to the + Challenges table and any child tables. + + :param challenge: + :param request: + :return: + """ + data = request.form or request.get_json() + for attr, value in data.items(): + # We need to set these to floats so that the next operations don't operate on strings + if attr in ("initial", "minimum", "decay"): + try: + value = float(value) + except (ValueError, TypeError): + db.session.rollback() + raise ChallengeUpdateException(f"Invalid input for '{attr}'") + setattr(challenge, attr, value) + + for attr in ("initial", "minimum", "decay"): + if ( + challenge.function in DECAY_FUNCTIONS + and getattr(challenge, attr) is None + ): + db.session.rollback() + raise ChallengeUpdateException( + f"Missing '{attr}' but function is {challenge.function}" + ) + + db.session.commit() + + # If the challenge is dynamic we should calculate a new value + if challenge.function in DECAY_FUNCTIONS: + return calculate_value(challenge) + + # If we don't support dynamic we just don't do anything + return challenge + + @classmethod + def delete(cls, challenge): + """ + This method is used to delete the resources used by a challenge. + + :param challenge: + :return: + """ + Fails.query.filter_by(challenge_id=challenge.id).delete() + Solves.query.filter_by(challenge_id=challenge.id).delete() + Flags.query.filter_by(challenge_id=challenge.id).delete() + files = ChallengeFiles.query.filter_by(challenge_id=challenge.id).all() + for f in files: + delete_file(f.id) + ChallengeFiles.query.filter_by(challenge_id=challenge.id).delete() + Tags.query.filter_by(challenge_id=challenge.id).delete() + Hints.query.filter_by(challenge_id=challenge.id).delete() + Challenges.query.filter_by(id=challenge.id).delete() + cls.challenge_model.query.filter_by(id=challenge.id).delete() + db.session.commit() + + @classmethod + def attempt(cls, challenge, request): + """ + This method is used to check whether a given input is right or wrong. It does not make any changes and should + return a boolean for correctness and a string to be shown to the user. It is also in charge of parsing the + user's input from the request itself. + + :param challenge: The Challenge object from the database + :param request: The request the user submitted + :return: (boolean, string) + """ + data = request.form or request.get_json() + submission = data["submission"].strip() + + flags = Flags.query.filter_by(challenge_id=challenge.id).all() + + if challenge.logic == "any": + return challenge_attempt_any(submission, challenge, flags) + elif challenge.logic == "all": + return challenge_attempt_all(submission, challenge, flags) + elif challenge.logic == "team": + return challenge_attempt_team(submission, challenge, flags) + else: + return challenge_attempt_any(submission, challenge, flags) + + @classmethod + def partial(cls, user, team, challenge, request): + data = request.form or request.get_json() + submission = data["submission"].strip() + partial = Partials( + user_id=user.id, + team_id=team.id if team else None, + challenge_id=challenge.id, + ip=get_ip(req=request), + provided=submission, + ) + db.session.add(partial) + db.session.commit() + + @classmethod + def ratelimited(cls, user, team, challenge, request): + data = request.form or request.get_json() + submission = data["submission"].strip() + partial = Ratelimiteds( + user_id=user.id, + team_id=team.id if team else None, + challenge_id=challenge.id, + ip=get_ip(req=request), + provided=submission, + ) + db.session.add(partial) + db.session.commit() + + @classmethod + def solve(cls, user, team, challenge, request): + """ + This method is used to insert Solves into the database in order to mark a challenge as solved. + + :param team: The Team object from the database + :param chal: The Challenge object from the database + :param request: The request the user submitted + :return: + """ + data = request.form or request.get_json() + submission = data["submission"].strip() + + solve = Solves( + user_id=user.id, + team_id=team.id if team else None, + challenge_id=challenge.id, + ip=get_ip(req=request), + provided=submission, + ) + + try: + db.session.add(solve) + db.session.commit() + except IntegrityError as e: + db.session.rollback() + raise ChallengeSolveException( + f"Duplicate solve for user {user.id} on challenge {challenge.id}" + ) from e + + # If the challenge is dynamic we should calculate a new value + if challenge.function in DECAY_FUNCTIONS: + calculate_value(challenge) + + @classmethod + def fail(cls, user, team, challenge, request): + """ + This method is used to insert Fails into the database in order to mark an answer incorrect. + + :param team: The Team object from the database + :param chal: The Challenge object from the database + :param request: The request the user submitted + :return: + """ + data = request.form or request.get_json() + submission = data["submission"].strip() + wrong = Fails( + user_id=user.id, + team_id=team.id if team else None, + challenge_id=challenge.id, + ip=get_ip(request), + provided=submission, + ) + db.session.add(wrong) + db.session.commit() + + +class CTFdStandardChallenge(BaseChallenge): + id = "standard" # Unique identifier used to register challenges + name = "standard" # Name of a challenge type + templates = { # Templates used for each aspect of challenge editing & viewing + "create": "/plugins/challenges/assets/create.html", + "update": "/plugins/challenges/assets/update.html", + "view": "/plugins/challenges/assets/view.html", + } + scripts = { # Scripts that are loaded when a template is loaded + "create": "/plugins/challenges/assets/create.js", + "update": "/plugins/challenges/assets/update.js", + "view": "/plugins/challenges/assets/view.js", + } + # Route at which files are accessible. This must be registered using register_plugin_assets_directory() + route = "/plugins/challenges/assets/" + # Blueprint used to access the static_folder directory. + blueprint = Blueprint( + "standard", __name__, template_folder="templates", static_folder="assets" + ) + challenge_model = Challenges + + +def get_chal_class(class_id): + """ + Utility function used to get the corresponding class from a class ID. + + :param class_id: String representing the class ID + :return: Challenge class + """ + cls = CHALLENGE_CLASSES.get(class_id) + if cls is None: + raise KeyError + return cls + + +""" +Global dictionary used to hold all the Challenge Type classes used by CTFd. Insert into this dictionary to register +your Challenge Type. +""" +CHALLENGE_CLASSES = {"standard": CTFdStandardChallenge} + + +def load(app): + register_plugin_assets_directory(app, base_path="/plugins/challenges/assets/") diff --git a/CTFd/plugins/challenges/assets/create.html b/CTFd/plugins/challenges/assets/create.html new file mode 100644 index 0000000..dd985c3 --- /dev/null +++ b/CTFd/plugins/challenges/assets/create.html @@ -0,0 +1 @@ +{% extends "admin/challenges/create.html" %} \ No newline at end of file diff --git a/CTFd/plugins/challenges/assets/create.js b/CTFd/plugins/challenges/assets/create.js new file mode 100644 index 0000000..bcfe9c3 --- /dev/null +++ b/CTFd/plugins/challenges/assets/create.js @@ -0,0 +1,4 @@ +CTFd.plugin.run((_CTFd) => { + const $ = _CTFd.lib.$ + const md = _CTFd.lib.markdown() +}) diff --git a/CTFd/plugins/challenges/assets/update.html b/CTFd/plugins/challenges/assets/update.html new file mode 100644 index 0000000..d097e1c --- /dev/null +++ b/CTFd/plugins/challenges/assets/update.html @@ -0,0 +1 @@ +{% extends "admin/challenges/update.html" %} \ No newline at end of file diff --git a/CTFd/plugins/challenges/assets/update.js b/CTFd/plugins/challenges/assets/update.js new file mode 100644 index 0000000..e69de29 diff --git a/CTFd/plugins/challenges/assets/view.html b/CTFd/plugins/challenges/assets/view.html new file mode 100644 index 0000000..ab623af --- /dev/null +++ b/CTFd/plugins/challenges/assets/view.html @@ -0,0 +1 @@ +{% extends "challenge.html" %} \ No newline at end of file diff --git a/CTFd/plugins/challenges/assets/view.js b/CTFd/plugins/challenges/assets/view.js new file mode 100644 index 0000000..b03a28e --- /dev/null +++ b/CTFd/plugins/challenges/assets/view.js @@ -0,0 +1,37 @@ +CTFd._internal.challenge.data = undefined; + +// TODO: Remove in CTFd v4.0 +CTFd._internal.challenge.renderer = null; + +CTFd._internal.challenge.preRender = function() {}; + +// TODO: Remove in CTFd v4.0 +CTFd._internal.challenge.render = null; + +CTFd._internal.challenge.postRender = function() {}; + +CTFd._internal.challenge.submit = function(preview) { + var challenge_id = parseInt(CTFd.lib.$("#challenge-id").val()); + var submission = CTFd.lib.$("#challenge-input").val(); + + var body = { + challenge_id: challenge_id, + submission: submission + }; + var params = {}; + if (preview) { + params["preview"] = true; + } + + return CTFd.api.post_challenge_attempt(params, body).then(function(response) { + if (response.status === 429) { + // User was ratelimited but process response + return response; + } + if (response.status === 403) { + // User is not logged in or CTF is paused. + return response; + } + return response; + }); +}; diff --git a/CTFd/plugins/challenges/decay.py b/CTFd/plugins/challenges/decay.py new file mode 100644 index 0000000..8db468f --- /dev/null +++ b/CTFd/plugins/challenges/decay.py @@ -0,0 +1,75 @@ +from __future__ import division # Use floating point for math calculations + +import math + +from CTFd.models import Solves +from CTFd.utils.modes import get_model + + +def get_solve_count(challenge): + Model = get_model() + + solve_count = ( + Solves.query.join(Model, Solves.account_id == Model.id) + .filter( + Solves.challenge_id == challenge.id, + Model.hidden == False, + Model.banned == False, + ) + .count() + ) + return solve_count + + +def linear(challenge): + solve_count = get_solve_count(challenge) + + # If the solve count is 0 we shouldn't manipulate the solve count to + # let the math update back to normal + if solve_count != 0: + # We subtract -1 to allow the first solver to get max point value + solve_count -= 1 + + value = challenge.initial - (challenge.decay * solve_count) + + value = math.ceil(value) + + if value < challenge.minimum: + value = challenge.minimum + + return value + + +def logarithmic(challenge): + solve_count = get_solve_count(challenge) + + # If the solve count is 0 we shouldn't manipulate the solve count to + # let the math update back to normal + if solve_count != 0: + # We subtract -1 to allow the first solver to get max point value + solve_count -= 1 + + # Handle situations where admins have entered a 0 decay + # This is invalid as it can cause a division by zero + if challenge.decay == 0: + challenge.decay = 1 + + # It is important that this calculation takes into account floats. + # Hence this file uses from __future__ import division + value = ( + ((challenge.minimum - challenge.initial) / (challenge.decay**2)) + * (solve_count**2) + ) + challenge.initial + + value = math.ceil(value) + + if value < challenge.minimum: + value = challenge.minimum + + return value + + +DECAY_FUNCTIONS = { + "linear": linear, + "logarithmic": logarithmic, +} diff --git a/CTFd/plugins/challenges/logic.py b/CTFd/plugins/challenges/logic.py new file mode 100644 index 0000000..b826065 --- /dev/null +++ b/CTFd/plugins/challenges/logic.py @@ -0,0 +1,130 @@ +from CTFd.models import Partials +from CTFd.plugins.flags import FlagException, get_flag_class +from CTFd.utils.config import is_teams_mode +from CTFd.utils.user import get_current_team, get_current_user + + +def challenge_attempt_any(submission, challenge, flags): + from CTFd.plugins.challenges import ChallengeResponse + + for flag in flags: + try: + if get_flag_class(flag.type).compare(flag, submission): + return ChallengeResponse( + status="correct", + message="Correct", + ) + except FlagException as e: + return ChallengeResponse( + status="incorrect", + message=str(e), + ) + return ChallengeResponse( + status="incorrect", + message="Incorrect", + ) + + +def challenge_attempt_all(submission, challenge, flags): + from CTFd.plugins.challenges import ChallengeResponse + + user = get_current_user() + partials = Partials.query.filter_by( + account_id=user.account_id, challenge_id=challenge.id + ).all() + provideds = [partial.provided for partial in partials] + provideds.append(submission) + + target_flags_ids = {flag.id for flag in flags} + compared_flag_ids = [] + + for flag in flags: + # Skip flags that we have already evaluated as captured + if flag.id in compared_flag_ids: + continue + flag_class = get_flag_class(flag.type) + for provided in provideds: + if flag_class.compare(flag, provided): + compared_flag_ids.append(flag.id) + + # If we have captured against all flag IDs the challenge is correct + if target_flags_ids == set(compared_flag_ids): + return ChallengeResponse( + status="correct", + message="Correct", + ) + + # If we didn't capture all flag IDs we must be missing something. + for flag in flags: + if get_flag_class(flag.type).compare(flag, submission): + return ChallengeResponse( + status="partial", + message="Correct but more flags are required", + ) + + # Input is just wrong + return ChallengeResponse( + status="incorrect", + message="Incorrect", + ) + + +def challenge_attempt_team(submission, challenge, flags): + from CTFd.plugins.challenges import ChallengeResponse + + if is_teams_mode(): + user = get_current_user() + team = get_current_team() + partials = Partials.query.filter_by( + team_id=team.id, challenge_id=challenge.id + ).all() + + submitter_ids = {partial.user_id for partial in partials} + + # Check if the user's submission is correct + for flag in flags: + try: + if get_flag_class(flag.type).compare(flag, submission): + submitter_ids.add(user.id) + break + except FlagException as e: + return ChallengeResponse( + status="incorrect", + message=str(e), + ) + else: + return ChallengeResponse( + status="incorrect", + message="Incorrect", + ) + + # The submission is correct so compare if we have received from all team members + member_ids = {member.id for member in team.members} + if member_ids == submitter_ids: + return ChallengeResponse( + status="correct", + message="Correct", + ) + else: + # We have not received from all members + return ChallengeResponse( + status="partial", + message="Correct but all team members must submit a flag", + ) + else: + for flag in flags: + try: + if get_flag_class(flag.type).compare(flag, submission): + return ChallengeResponse( + status="correct", + message="Correct", + ) + except FlagException as e: + return ChallengeResponse( + status="incorrect", + message=str(e), + ) + return ChallengeResponse( + status="incorrect", + message="Incorrect", + ) diff --git a/CTFd/plugins/ctfd-whale/.gitignore b/CTFd/plugins/ctfd-whale/.gitignore new file mode 100644 index 0000000..4a5bb25 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.py[cod] +.DS_Store diff --git a/CTFd/plugins/ctfd-whale/CHANGELOG.md b/CTFd/plugins/ctfd-whale/CHANGELOG.md new file mode 100644 index 0000000..c2360a8 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/CHANGELOG.md @@ -0,0 +1,91 @@ +# Changelog + +## 2020-03-18 + +- Allow non-dynamic flag. + +## 2020-02-18 + +- Refine front for ctfd newer version.(@frankli0324) + +## 2019-11-21 + +- Add network prefix & timeout setting. +- Refine port and network range search +- Refine frp request +- Refine lock timeout + +## 2019-11-08 + +- Add Lan Domain + +## 2019-11-04 + +- Change backend to Docker Swarm. +- Support depoly different os image to different os node. + +You should init docker swarm, and add your node to it. And name them with following command: + +``` +docker node update --label-add name=windows-1 **** +docker node update --label-add name=linux-1 **** +``` + +Name of them should begin with windows- or linux-. + +And put them in the setting panel. + +Then if you want to deploy a instance to windows node, You should tag your name with prefix "windows", like "glzjin/super_sql:windows". + +And please modify the container network driver to 'Overlay'! + +## 2019-10-30 + +- Optimize for multi worker. +- Try to fix concurrency request problem. + +Now You should set the redis with REDIS_HOST environment varible. + +## 2019-09-26 + +- Add frp http port setting. + +You should config it at the settings for http redirect. + +## 2019-09-15 + +- Add Container Network Setting and DNS Setting. + +Now You can setup a DNS Server in your Container Network. +- For single-instance network, Just connect your dns server to it and input the ip address in the seeting panel. +- For multi-instance network, You should rename the dns server to a name include "dns", than add it to auto connect instance. It will be used as a dns server. + +## 2019-09-14 + +- Refine plugin path. + +## 2019-09-13 + +- Refine removal. + +## 2019-08-29 + +- Add CPU usage limit. +- Allow the multi-image challenge. + +Upgrade: +1. Execute this SQL in ctfd database. + +``` +alter table dynamic_docker_challenge add column cpu_limit float default 0.5 after memory_limit; +``` + +2. Setting the containers you want to plugin to a single multi-image network. (In settings panel) + +3. When you create a challenge you can set the docker image like this + +``` +{"socks": "serjs/go-socks5-proxy", "web": "blog_revenge_blog", "mysql": "blog_revenge_mysql", "oauth": "blog_revenge_oauth"} +``` + +The first one will be redirected the traffic. diff --git a/CTFd/plugins/ctfd-whale/LICENSE b/CTFd/plugins/ctfd-whale/LICENSE new file mode 100644 index 0000000..97f7a6d --- /dev/null +++ b/CTFd/plugins/ctfd-whale/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 glzjin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/CTFd/plugins/ctfd-whale/README.md b/CTFd/plugins/ctfd-whale/README.md new file mode 100644 index 0000000..799b089 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/README.md @@ -0,0 +1,40 @@ +# CTFd-Whale + +## [中文README](README.zh-cn.md) + +A plugin that empowers CTFd to bring up separate environments for each user + +## Features + +- Deploys containers with `frp` and `docker swarm` +- Supports subdomain access by utilizing `frp` +- Contestants can start/renew/destroy their environments with a single click +- flags and subdomains are generated automatically with configurable rules +- Administrators can get a full list of running containers, with full control over them. + +## Installation & Usage + +refer to [installation guide](docs/install.md) + +## Demo + +[BUUCTF](https://buuoj.cn) + +## Third-party Introductions (zh-CN) + +- [CTFd-Whale 推荐部署实践](https://www.zhaoj.in/read-6333.html) +- [手把手教你如何建立一个支持ctf动态独立靶机的靶场(ctfd+ctfd-whale)](https://blog.csdn.net/fjh1997/article/details/100850756) + +## Screenshots + +![](https://user-images.githubusercontent.com/20221896/105939593-7cca6f80-6094-11eb-92de-8a04554dc019.png) + +![image](https://user-images.githubusercontent.com/20221896/105940182-a637cb00-6095-11eb-9525-8291986520c1.png) + +![](https://user-images.githubusercontent.com/20221896/105939965-2e69a080-6095-11eb-9b31-7777a0cc41b9.png) + +![](https://user-images.githubusercontent.com/20221896/105940026-50632300-6095-11eb-8512-6f19dd12c776.png) + +## Twin Project + +- [CTFd-Owl](https://github.com/D0g3-Lab/H1ve/tree/master/CTFd/plugins/ctfd-owl) (支持部署compose) diff --git a/CTFd/plugins/ctfd-whale/README.zh-cn.md b/CTFd/plugins/ctfd-whale/README.zh-cn.md new file mode 100644 index 0000000..d59c3e8 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/README.zh-cn.md @@ -0,0 +1,39 @@ +# CTFd-Whale + +能够支持题目容器化部署的CTFd插件 + +## 功能 + +- 利用`frp`与`docker swarm`做到多容器部署 +- web题目支持利用frp的subdomain实现每个用户单独的域名访问 +- 参赛选手一键启动题目环境,支持容器续期 +- 自动生成随机flag,并通过环境变量传入容器 +- 管理员可以在后台查看启动的容器 +- 支持自定义flag生成方式与web题目子域名生成方式 + +## 使用方式 + +请参考[安装指南](docs/install.zh-cn.md) + +## Demo + +[BUUCTF](https://buuoj.cn) + +## 第三方使用说明 + +- [CTFd-Whale 推荐部署实践](https://www.zhaoj.in/read-6333.html) +- [手把手教你如何建立一个支持ctf动态独立靶机的靶场(ctfd+ctfd-whale)](https://blog.csdn.net/fjh1997/article/details/100850756) + +## 使用案例 + +![](https://user-images.githubusercontent.com/20221896/105939593-7cca6f80-6094-11eb-92de-8a04554dc019.png) + +![image](https://user-images.githubusercontent.com/20221896/105940182-a637cb00-6095-11eb-9525-8291986520c1.png) + +![](https://user-images.githubusercontent.com/20221896/105939965-2e69a080-6095-11eb-9b31-7777a0cc41b9.png) + +![](https://user-images.githubusercontent.com/20221896/105940026-50632300-6095-11eb-8512-6f19dd12c776.png) + +## 友情链接 + +- [CTFd-Owl](https://github.com/D0g3-Lab/H1ve/tree/master/CTFd/plugins/ctfd-owl) (支持部署compose) diff --git a/CTFd/plugins/ctfd-whale/__init__.py b/CTFd/plugins/ctfd-whale/__init__.py new file mode 100644 index 0000000..5f94f95 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/__init__.py @@ -0,0 +1,124 @@ +import fcntl +import warnings + +import requests +from flask import Blueprint, render_template, session, current_app, request +from flask_apscheduler import APScheduler + +from CTFd.api import CTFd_API_v1 +from CTFd.plugins import ( + register_plugin_assets_directory, + register_admin_plugin_menu_bar, +) +from CTFd.plugins.challenges import CHALLENGE_CLASSES +from CTFd.utils import get_config, set_config +from CTFd.utils.decorators import admins_only + +from .api import user_namespace, admin_namespace, AdminContainers +from .challenge_type import DynamicValueDockerChallenge +from .utils.checks import WhaleChecks +from .utils.control import ControlUtil +from .utils.db import DBContainer +from .utils.docker import DockerUtils +from .utils.exceptions import WhaleWarning +from .utils.setup import setup_default_configs +from .utils.routers import Router + + +def load(app): + app.config['RESTX_ERROR_404_HELP'] = False + # upgrade() + plugin_name = __name__.split('.')[-1] + set_config('whale:plugin_name', plugin_name) + app.db.create_all() + if not get_config("whale:setup"): + setup_default_configs() + + register_plugin_assets_directory( + app, base_path=f"/plugins/{plugin_name}/assets", + endpoint='plugins.ctfd-whale.assets' + ) + register_admin_plugin_menu_bar( + title='Whale', + route='/plugins/ctfd-whale/admin/settings' + ) + + DynamicValueDockerChallenge.templates = { + "create": f"/plugins/{plugin_name}/assets/create.html", + "update": f"/plugins/{plugin_name}/assets/update.html", + "view": f"/plugins/{plugin_name}/assets/view.html", + } + DynamicValueDockerChallenge.scripts = { + "create": "/plugins/ctfd-whale/assets/create.js", + "update": "/plugins/ctfd-whale/assets/update.js", + "view": "/plugins/ctfd-whale/assets/view.js", + } + CHALLENGE_CLASSES["dynamic_docker"] = DynamicValueDockerChallenge + + page_blueprint = Blueprint( + "ctfd-whale", + __name__, + template_folder="templates", + static_folder="assets", + url_prefix="/plugins/ctfd-whale" + ) + CTFd_API_v1.add_namespace(admin_namespace, path="/plugins/ctfd-whale/admin") + CTFd_API_v1.add_namespace(user_namespace, path="/plugins/ctfd-whale") + + worker_config_commit = None + + @page_blueprint.route('/admin/settings') + @admins_only + def admin_list_configs(): + nonlocal worker_config_commit + errors = WhaleChecks.perform() + if not errors and get_config("whale:refresh") != worker_config_commit: + worker_config_commit = get_config("whale:refresh") + DockerUtils.init() + Router.reset() + set_config("whale:refresh", "false") + return render_template('whale_config.html', errors=errors) + + @page_blueprint.route("/admin/containers") + @admins_only + def admin_list_containers(): + result = AdminContainers.get() + view_mode = request.args.get('mode', session.get('view_mode', 'list')) + session['view_mode'] = view_mode + return render_template("whale_containers.html", + plugin_name=plugin_name, + containers=result['data']['containers'], + pages=result['data']['pages'], + curr_page=abs(request.args.get("page", 1, type=int)), + curr_page_start=result['data']['page_start']) + + def auto_clean_container(): + with app.app_context(): + results = DBContainer.get_all_expired_container() + for r in results: + ControlUtil.try_remove_container(r.user_id) + + app.register_blueprint(page_blueprint) + + try: + Router.check_availability() + DockerUtils.init() + except Exception: + warnings.warn("Initialization Failed. Please check your configs.", WhaleWarning) + + try: + lock_file = open("/tmp/ctfd_whale.lock", "w") + lock_fd = lock_file.fileno() + fcntl.lockf(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + + scheduler = APScheduler() + scheduler.init_app(app) + scheduler.start() + scheduler.add_job( + id='whale-auto-clean', func=auto_clean_container, + trigger="interval", seconds=10 + ) + + print("[CTFd Whale] Started successfully") + except IOError: + pass diff --git a/CTFd/plugins/ctfd-whale/api.py b/CTFd/plugins/ctfd-whale/api.py new file mode 100644 index 0000000..3944939 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/api.py @@ -0,0 +1,138 @@ +from datetime import datetime + +from flask import request +from flask_restx import Namespace, Resource, abort + +from CTFd.utils import get_config +from CTFd.utils import user as current_user +from CTFd.utils.decorators import admins_only, authed_only + +from .decorators import challenge_visible, frequency_limited +from .utils.control import ControlUtil +from .utils.db import DBContainer +from .utils.routers import Router + +admin_namespace = Namespace("ctfd-whale-admin") +user_namespace = Namespace("ctfd-whale-user") + + +@admin_namespace.errorhandler +@user_namespace.errorhandler +def handle_default(err): + return { + 'success': False, + 'message': 'Unexpected things happened' + }, 500 + + +@admin_namespace.route('/container') +class AdminContainers(Resource): + @staticmethod + @admins_only + def get(): + page = abs(request.args.get("page", 1, type=int)) + results_per_page = abs(request.args.get("per_page", 20, type=int)) + page_start = results_per_page * (page - 1) + page_end = results_per_page * (page - 1) + results_per_page + + count = DBContainer.get_all_alive_container_count() + containers = DBContainer.get_all_alive_container_page( + page_start, page_end) + + return {'success': True, 'data': { + 'containers': containers, + 'total': count, + 'pages': int(count / results_per_page) + (count % results_per_page > 0), + 'page_start': page_start, + }} + + @staticmethod + @admins_only + def patch(): + user_id = request.args.get('user_id', -1) + result, message = ControlUtil.try_renew_container(user_id=int(user_id)) + if not result: + abort(403, message, success=False) + return {'success': True, 'message': message} + + @staticmethod + @admins_only + def delete(): + user_id = request.args.get('user_id') + result, message = ControlUtil.try_remove_container(user_id) + return {'success': result, 'message': message} + + +@user_namespace.route("/container") +class UserContainers(Resource): + @staticmethod + @authed_only + @challenge_visible + def get(): + user_id = current_user.get_current_user().id + challenge_id = request.args.get('challenge_id') + container = DBContainer.get_current_containers(user_id=user_id) + if not container: + return {'success': True, 'data': {}} + timeout = int(get_config("whale:docker_timeout", "3600")) + c = container.challenge # build a url for quick jump. todo: escape dash in categories and names. + link = f'{c.name}' + if int(container.challenge_id) != int(challenge_id): + return abort(403, f'Container already started but not from this challenge ({link})', success=False) + return { + 'success': True, + 'data': { + 'lan_domain': str(user_id) + "-" + container.uuid, + 'user_access': Router.access(container), + 'remaining_time': timeout - (datetime.now() - container.start_time).seconds, + } + } + + @staticmethod + @authed_only + @challenge_visible + @frequency_limited + def post(): + user_id = current_user.get_current_user().id + ControlUtil.try_remove_container(user_id) + + current_count = DBContainer.get_all_alive_container_count() + if int(get_config("whale:docker_max_container_count")) <= int(current_count): + abort(403, 'Max container count exceed.', success=False) + + challenge_id = request.args.get('challenge_id') + result, message = ControlUtil.try_add_container( + user_id=user_id, + challenge_id=challenge_id + ) + if not result: + abort(403, message, success=False) + return {'success': True, 'message': message} + + @staticmethod + @authed_only + @challenge_visible + @frequency_limited + def patch(): + user_id = current_user.get_current_user().id + challenge_id = request.args.get('challenge_id') + docker_max_renew_count = int(get_config("whale:docker_max_renew_count", 5)) + container = DBContainer.get_current_containers(user_id) + if container is None: + abort(403, 'Instance not found.', success=False) + if int(container.challenge_id) != int(challenge_id): + abort(403, f'Container started but not from this challenge({container.challenge.name})', success=False) + if container.renew_count >= docker_max_renew_count: + abort(403, 'Max renewal count exceed.', success=False) + result, message = ControlUtil.try_renew_container(user_id=user_id) + return {'success': result, 'message': message} + + @staticmethod + @authed_only + @frequency_limited + def delete(): + user_id = current_user.get_current_user().id + result, message = ControlUtil.try_remove_container(user_id) + if not result: + abort(403, message, success=False) + return {'success': True, 'message': message} diff --git a/CTFd/plugins/ctfd-whale/assets/config.js b/CTFd/plugins/ctfd-whale/assets/config.js new file mode 100644 index 0000000..34839c2 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/assets/config.js @@ -0,0 +1,27 @@ +const $ = CTFd.lib.$; + +$(".config-section > form:not(.form-upload)").submit(async function (event) { + event.preventDefault(); + const obj = $(this).serializeJSON(); + const params = {}; + for (let x in obj) { + if (obj[x] === "true") { + params[x] = true; + } else if (obj[x] === "false") { + params[x] = false; + } else { + params[x] = obj[x]; + } + } + params['whale:refresh'] = btoa(+new Date).slice(-7, -2); + + await CTFd.api.patch_config_list({}, params); + location.reload(); +}); +$(".config-section > form:not(.form-upload) > div > div > div > #router-type").change(async function () { + await CTFd.api.patch_config_list({}, { + 'whale:router_type': $(this).val(), + 'whale:refresh': btoa(+new Date).slice(-7, -2), + }); + location.reload(); +}); diff --git a/CTFd/plugins/ctfd-whale/assets/containers.js b/CTFd/plugins/ctfd-whale/assets/containers.js new file mode 100644 index 0000000..d7ae0d9 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/assets/containers.js @@ -0,0 +1,120 @@ +const $ = CTFd.lib.$; + +function htmlentities(str) { + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function copyToClipboard(event, str) { + // Select element + const el = document.createElement('textarea'); + el.value = str; + el.setAttribute('readonly', ''); + el.style.position = 'absolute'; + el.style.left = '-9999px'; + document.body.appendChild(el); + el.select(); + document.execCommand('copy'); + document.body.removeChild(el); + + $(event.target).tooltip({ + title: "Copied!", + trigger: "manual" + }); + $(event.target).tooltip("show"); + + setTimeout(function () { + $(event.target).tooltip("hide"); + }, 1500); +} + +$(".click-copy").click(function (e) { + copyToClipboard(e, $(this).data("copy")); +}) + +async function delete_container(user_id) { + let response = await CTFd.fetch("/api/v1/plugins/ctfd-whale/admin/container?user_id=" + user_id, { + method: "DELETE", + credentials: "same-origin", + headers: { + Accept: "application/json", + "Content-Type": "application/json" + } + }); + response = await response.json(); + return response.success; +} +async function renew_container(user_id) { + let response = await CTFd.fetch( + "/api/v1/plugins/ctfd-whale/admin/container?user_id=" + user_id, { + method: "PATCH", + credentials: "same-origin", + headers: { + Accept: "application/json", + "Content-Type": "application/json" + } + }); + response = await response.json(); + return response.success; +} + +$('#containers-renew-button').click(function (e) { + let users = $("input[data-user-id]:checked").map(function () { + return $(this).data("user-id"); + }); + CTFd.ui.ezq.ezQuery({ + title: "Renew Containers", + body: `Are you sure you want to renew the selected ${users.length} container(s)?`, + success: async function () { + await Promise.all(users.toArray().map((user) => renew_container(user))); + location.reload(); + } + }); +}); + +$('#containers-delete-button').click(function (e) { + let users = $("input[data-user-id]:checked").map(function () { + return $(this).data("user-id"); + }); + CTFd.ui.ezq.ezQuery({ + title: "Delete Containers", + body: `Are you sure you want to delete the selected ${users.length} container(s)?`, + success: async function () { + await Promise.all(users.toArray().map((user) => delete_container(user))); + location.reload(); + } + }); +}); + +$(".delete-container").click(function (e) { + e.preventDefault(); + let container_id = $(this).attr("container-id"); + let user_id = $(this).attr("user-id"); + + CTFd.ui.ezq.ezQuery({ + title: "Destroy Container", + body: "Are you sure you want to delete Container #{0}?".format( + htmlentities(container_id) + ), + success: async function () { + await delete_container(user_id); + location.reload(); + } + }); +}); + +$(".renew-container").click(function (e) { + e.preventDefault(); + let container_id = $(this).attr("container-id"); + let user_id = $(this).attr("user-id"); + + CTFd.ui.ezq.ezQuery({ + title: "Renew Container", + body: "Are you sure you want to renew Container #{0}?".format( + htmlentities(container_id) + ), + success: async function () { + await renew_container(user_id); + location.reload(); + }, + }); +}); \ No newline at end of file diff --git a/CTFd/plugins/ctfd-whale/assets/create.html b/CTFd/plugins/ctfd-whale/assets/create.html new file mode 100644 index 0000000..459d667 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/assets/create.html @@ -0,0 +1,100 @@ +{% extends "admin/challenges/create.html" %} + +{% block header %} + +{% endblock %} + + +{% block value %} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+{% endblock %} + +{% block type %} + +{% endblock %} \ No newline at end of file diff --git a/CTFd/plugins/ctfd-whale/assets/create.js b/CTFd/plugins/ctfd-whale/assets/create.js new file mode 100644 index 0000000..78ad73e --- /dev/null +++ b/CTFd/plugins/ctfd-whale/assets/create.js @@ -0,0 +1,30 @@ +// Markdown Preview +if ($ === undefined) $ = CTFd.lib.$ +$('#desc-edit').on('shown.bs.tab', function(event) { + if (event.target.hash == '#desc-preview') { + var editor_value = $('#desc-editor').val(); + $(event.target.hash).html( + CTFd._internal.challenge.render(editor_value) + ); + } +}); +$('#new-desc-edit').on('shown.bs.tab', function(event) { + if (event.target.hash == '#new-desc-preview') { + var editor_value = $('#new-desc-editor').val(); + $(event.target.hash).html( + CTFd._internal.challenge.render(editor_value) + ); + } +}); +$("#solve-attempts-checkbox").change(function() { + if (this.checked) { + $('#solve-attempts-input').show(); + } else { + $('#solve-attempts-input').hide(); + $('#max_attempts').val(''); + } +}); + +$(document).ready(function() { + $('[data-toggle="tooltip"]').tooltip(); +}); \ No newline at end of file diff --git a/CTFd/plugins/ctfd-whale/assets/update.html b/CTFd/plugins/ctfd-whale/assets/update.html new file mode 100644 index 0000000..a3415d8 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/assets/update.html @@ -0,0 +1,94 @@ +{% extends "admin/challenges/update.html" %} + +{% block value %} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+{% endblock %} \ No newline at end of file diff --git a/CTFd/plugins/ctfd-whale/assets/update.js b/CTFd/plugins/ctfd-whale/assets/update.js new file mode 100644 index 0000000..a45103a --- /dev/null +++ b/CTFd/plugins/ctfd-whale/assets/update.js @@ -0,0 +1,52 @@ +if ($ === undefined) $ = CTFd.lib.$ +$('#submit-key').click(function(e) { + submitkey($('#chalid').val(), $('#answer').val()) +}); + +$('#submit-keys').click(function(e) { + e.preventDefault(); + $('#update-keys').modal('hide'); +}); + +$('#limit_max_attempts').change(function() { + if (this.checked) { + $('#chal-attempts-group').show(); + } else { + $('#chal-attempts-group').hide(); + $('#chal-attempts-input').val(''); + } +}); + +// Markdown Preview +$('#desc-edit').on('shown.bs.tab', function(event) { + if (event.target.hash == '#desc-preview') { + var editor_value = $('#desc-editor').val(); + $(event.target.hash).html( + window.challenge.render(editor_value) + ); + } +}); +$('#new-desc-edit').on('shown.bs.tab', function(event) { + if (event.target.hash == '#new-desc-preview') { + var editor_value = $('#new-desc-editor').val(); + $(event.target.hash).html( + window.challenge.render(editor_value) + ); + } +}); + +function loadchal(id, update) { + $.get(script_root + '/admin/chal/' + id, function(obj) { + $('#desc-write-link').click(); // Switch to Write tab + if (typeof update === 'undefined') + $('#update-challenge').modal(); + }); +} + +function openchal(id) { + loadchal(id); +} + +$(document).ready(function() { + $('[data-toggle="tooltip"]').tooltip(); +}); \ No newline at end of file diff --git a/CTFd/plugins/ctfd-whale/assets/view.html b/CTFd/plugins/ctfd-whale/assets/view.html new file mode 100644 index 0000000..5803732 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/assets/view.html @@ -0,0 +1,36 @@ +{% extends "challenge.html" %} + +{% block description %} +{{ challenge.html }} +
+
+
+
+
Instance Info
+ +
+
+
+
+
Instance Info
+
+ Remaining Time: s +
+
+ Lan Domain: +
+

+ + +
+
+
+
+{% endblock %} diff --git a/CTFd/plugins/ctfd-whale/assets/view.js b/CTFd/plugins/ctfd-whale/assets/view.js new file mode 100644 index 0000000..ee572f7 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/assets/view.js @@ -0,0 +1,239 @@ +CTFd._internal.challenge.data = undefined + +CTFd._internal.challenge.renderer = null; + +CTFd._internal.challenge.preRender = function () { +} + +CTFd._internal.challenge.render = null; + +CTFd._internal.challenge.postRender = function () { + loadInfo(); +} + +if (window.$ === undefined) window.$ = CTFd.lib.$; + +function loadInfo() { + var challenge_id = CTFd._internal.challenge.data.id; + var url = "/api/v1/plugins/ctfd-whale/container?challenge_id=" + challenge_id; + + CTFd.fetch(url, { + method: 'GET', + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + }).then(function (response) { + if (response.status === 429) { + // User was ratelimited but process response + return response.json(); + } + if (response.status === 403) { + // User is not logged in or CTF is paused. + return response.json(); + } + return response.json(); + }).then(function (response) { + if (window.t !== undefined) { + clearInterval(window.t); + window.t = undefined; + } + if (response.success) response = response.data; + else CTFd._functions.events.eventAlert({ + title: "Fail", + html: response.message, + button: "OK" + }); + if (response.remaining_time != undefined) { + $('#whale-challenge-user-access').html(response.user_access); + $('#whale-challenge-lan-domain').html(response.lan_domain); + $('#whale-challenge-count-down').text(response.remaining_time); + $('#whale-panel-stopped').hide(); + $('#whale-panel-started').show(); + + window.t = setInterval(() => { + const c = $('#whale-challenge-count-down').text(); + if (!c) return; + let second = parseInt(c) - 1; + if (second <= 0) { + loadInfo(); + } + $('#whale-challenge-count-down').text(second); + }, 1000); + } else { + $('#whale-panel-started').hide(); + $('#whale-panel-stopped').show(); + } + }); +}; + +CTFd._internal.challenge.destroy = function () { + var challenge_id = CTFd._internal.challenge.data.id; + var url = "/api/v1/plugins/ctfd-whale/container?challenge_id=" + challenge_id; + + $('#whale-button-destroy').text("Waiting..."); + $('#whale-button-destroy').prop('disabled', true); + + var params = {}; + + CTFd.fetch(url, { + method: 'DELETE', + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(params) + }).then(function (response) { + if (response.status === 429) { + // User was ratelimited but process response + return response.json(); + } + if (response.status === 403) { + // User is not logged in or CTF is paused. + return response.json(); + } + return response.json(); + }).then(function (response) { + if (response.success) { + loadInfo(); + CTFd._functions.events.eventAlert({ + title: "Success", + html: "Your instance has been destroyed!", + button: "OK" + }); + } else { + CTFd._functions.events.eventAlert({ + title: "Fail", + html: response.message, + button: "OK" + }); + } + }).finally(() => { + $('#whale-button-destroy').text("Destroy this instance"); + $('#whale-button-destroy').prop('disabled', false); + }); +}; + +CTFd._internal.challenge.renew = function () { + var challenge_id = CTFd._internal.challenge.data.id; + var url = "/api/v1/plugins/ctfd-whale/container?challenge_id=" + challenge_id; + + $('#whale-button-renew').text("Waiting..."); + $('#whale-button-renew').prop('disabled', true); + + var params = {}; + + CTFd.fetch(url, { + method: 'PATCH', + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(params) + }).then(function (response) { + if (response.status === 429) { + // User was ratelimited but process response + return response.json(); + } + if (response.status === 403) { + // User is not logged in or CTF is paused. + return response.json(); + } + return response.json(); + }).then(function (response) { + if (response.success) { + loadInfo(); + CTFd._functions.events.eventAlert({ + title: "Success", + html: "Your instance has been renewed!", + button: "OK" + }); + } else { + CTFd._functions.events.eventAlert({ + title: "Fail", + html: response.message, + button: "OK" + }); + } + }).finally(() => { + $('#whale-button-renew').text("Renew this instance"); + $('#whale-button-renew').prop('disabled', false); + }); +}; + +CTFd._internal.challenge.boot = function () { + var challenge_id = CTFd._internal.challenge.data.id; + var url = "/api/v1/plugins/ctfd-whale/container?challenge_id=" + challenge_id; + + $('#whale-button-boot').text("Waiting..."); + $('#whale-button-boot').prop('disabled', true); + + var params = {}; + + CTFd.fetch(url, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(params) + }).then(function (response) { + if (response.status === 429) { + // User was ratelimited but process response + return response.json(); + } + if (response.status === 403) { + // User is not logged in or CTF is paused. + return response.json(); + } + return response.json(); + }).then(function (response) { + if (response.success) { + loadInfo(); + CTFd._functions.events.eventAlert({ + title: "Success", + html: "Your instance has been deployed!", + button: "OK" + }); + } else { + CTFd._functions.events.eventAlert({ + title: "Fail", + html: response.message, + button: "OK" + }); + } + }).finally(() => { + $('#whale-button-boot').text("Launch an instance"); + $('#whale-button-boot').prop('disabled', false); + }); +}; + + +CTFd._internal.challenge.submit = function (preview) { + var challenge_id = CTFd._internal.challenge.data.id; + var submission = $('#challenge-input').val() + + var body = { + 'challenge_id': challenge_id, + 'submission': submission, + } + var params = {} + if (preview) + params['preview'] = true + + return CTFd.api.post_challenge_attempt(params, body).then(function (response) { + if (response.status === 429) { + // User was ratelimited but process response + return response + } + if (response.status === 403) { + // User is not logged in or CTF is paused. + return response + } + return response + }) +}; diff --git a/CTFd/plugins/ctfd-whale/challenge_type.py b/CTFd/plugins/ctfd-whale/challenge_type.py new file mode 100644 index 0000000..628e6df --- /dev/null +++ b/CTFd/plugins/ctfd-whale/challenge_type.py @@ -0,0 +1,108 @@ +from flask import Blueprint + +from CTFd.models import ( + db, + Flags, +) +from CTFd.plugins.challenges import BaseChallenge +from CTFd.plugins.dynamic_challenges import DynamicValueChallenge +from CTFd.plugins.flags import get_flag_class +from CTFd.utils import user as current_user +from .models import WhaleContainer, DynamicDockerChallenge +from .utils.control import ControlUtil + + +class DynamicValueDockerChallenge(BaseChallenge): + id = "dynamic_docker" # Unique identifier used to register challenges + name = "dynamic_docker" # Name of a challenge type + # Blueprint used to access the static_folder directory. + blueprint = Blueprint( + "ctfd-whale-challenge", + __name__, + template_folder="templates", + static_folder="assets", + ) + challenge_model = DynamicDockerChallenge + + @classmethod + def read(cls, challenge): + challenge = DynamicDockerChallenge.query.filter_by(id=challenge.id).first() + data = { + "id": challenge.id, + "name": challenge.name, + "value": challenge.value, + "initial": challenge.initial, + "decay": challenge.decay, + "minimum": challenge.minimum, + "description": challenge.description, + "category": challenge.category, + "state": challenge.state, + "max_attempts": challenge.max_attempts, + "type": challenge.type, + "type_data": { + "id": cls.id, + "name": cls.name, + "templates": cls.templates, + "scripts": cls.scripts, + }, + } + return data + + @classmethod + def update(cls, challenge, request): + data = request.form or request.get_json() + + for attr, value in data.items(): + # We need to set these to floats so that the next operations don't operate on strings + if attr in ("initial", "minimum", "decay"): + value = float(value) + if attr == 'dynamic_score': + value = int(value) + setattr(challenge, attr, value) + + if challenge.dynamic_score == 1: + return DynamicValueChallenge.calculate_value(challenge) + + db.session.commit() + return challenge + + @classmethod + def attempt(cls, challenge, request): + data = request.form or request.get_json() + submission = data["submission"].strip() + + flags = Flags.query.filter_by(challenge_id=challenge.id).all() + + if len(flags) > 0: + for flag in flags: + if get_flag_class(flag.type).compare(flag, submission): + return True, "Correct" + return False, "Incorrect" + else: + user_id = current_user.get_current_user().id + q = db.session.query(WhaleContainer) + q = q.filter(WhaleContainer.user_id == user_id) + q = q.filter(WhaleContainer.challenge_id == challenge.id) + records = q.all() + if len(records) == 0: + return False, "Please solve it during the container is running" + + container = records[0] + if container.flag == submission: + return True, "Correct" + return False, "Incorrect" + + @classmethod + def solve(cls, user, team, challenge, request): + super().solve(user, team, challenge, request) + + if challenge.dynamic_score == 1: + DynamicValueChallenge.calculate_value(challenge) + + @classmethod + def delete(cls, challenge): + for container in WhaleContainer.query.filter_by( + challenge_id=challenge.id + ).all(): + ControlUtil.try_remove_container(container.user_id) + super().delete(challenge) diff --git a/CTFd/plugins/ctfd-whale/decorators.py b/CTFd/plugins/ctfd-whale/decorators.py new file mode 100644 index 0000000..aab3fae --- /dev/null +++ b/CTFd/plugins/ctfd-whale/decorators.py @@ -0,0 +1,53 @@ +import functools +import time +from flask import request, current_app, session +from flask_restx import abort +from sqlalchemy.sql import and_ + +from CTFd.models import Challenges +from CTFd.utils.user import is_admin, get_current_user +from .utils.cache import CacheProvider + + +def challenge_visible(func): + @functools.wraps(func) + def _challenge_visible(*args, **kwargs): + challenge_id = request.args.get('challenge_id') + if is_admin(): + if not Challenges.query.filter( + Challenges.id == challenge_id + ).first(): + abort(404, 'no such challenge', success=False) + else: + if not Challenges.query.filter( + Challenges.id == challenge_id, + and_(Challenges.state != "hidden", Challenges.state != "locked"), + ).first(): + abort(403, 'challenge not visible', success=False) + return func(*args, **kwargs) + + return _challenge_visible + + +def frequency_limited(func): + @functools.wraps(func) + def _frequency_limited(*args, **kwargs): + if is_admin(): + return func(*args, **kwargs) + redis_util = CacheProvider(app=current_app, user_id=get_current_user().id) + if not redis_util.acquire_lock(): + abort(403, 'Request Too Fast!', success=False) + # last request was unsuccessful. this is for protection. + + if "limit" not in session: + session["limit"] = int(time.time()) + else: + if int(time.time()) - session["limit"] < 60: + abort(403, 'Frequency limit, You should wait at least 1 min.', success=False) + session["limit"] = int(time.time()) + + result = func(*args, **kwargs) + redis_util.release_lock() # if any exception is raised, lock will not be released + return result + + return _frequency_limited diff --git a/CTFd/plugins/ctfd-whale/docker-compose.example.yml b/CTFd/plugins/ctfd-whale/docker-compose.example.yml new file mode 100644 index 0000000..686f067 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/docker-compose.example.yml @@ -0,0 +1,105 @@ +version: '3.7' + +services: + ctfd: + build: . + user: root + restart: always + ports: + - "8000:8000" + environment: + - UPLOAD_FOLDER=/var/uploads + - DATABASE_URL=mysql+pymysql://ctfd:ctfd@db/ctfd + - REDIS_URL=redis://cache:6379 + - WORKERS=1 + - LOG_FOLDER=/var/log/CTFd + - ACCESS_LOG=- + - ERROR_LOG=- + - REVERSE_PROXY=true + volumes: + - .data/CTFd/logs:/var/log/CTFd + - .data/CTFd/uploads:/var/uploads + - .:/opt/CTFd:ro + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + - db + networks: + default: + internal: + + nginx: + image: nginx:1.17 + restart: always + volumes: + - ./conf/nginx/http.conf:/etc/nginx/nginx.conf + ports: + - 80:80 + depends_on: + - ctfd + + db: + image: mariadb:10.4.12 + restart: always + environment: + - MYSQL_ROOT_PASSWORD=ctfd + - MYSQL_USER=ctfd + - MYSQL_PASSWORD=ctfd + - MYSQL_DATABASE=ctfd + volumes: + - .data/mysql:/var/lib/mysql + networks: + internal: + # This command is required to set important mariadb defaults + command: [mysqld, --character-set-server=utf8mb4, --collation-server=utf8mb4_unicode_ci, --wait_timeout=28800, --log-warnings=0] + + cache: + image: redis:4 + restart: always + volumes: + - .data/redis:/data + networks: + internal: + + frpc: + image: frankli0324/frp:frpc + restart: always + command: [ + "--server_addr=frps", + "--server_port=7000", + "--token=your_token", + "--admin_addr=0.0.0.0", + "--admin_port=7000", + "--admin_user=frank", + "--admin_pwd=qwer", + ] + networks: + frp: + internal: + containers: + + frps: + image: frankli0324/frp:frps + restart: always + command: [ + "--bind_addr=0.0.0.0", + "--bind_port=7000", + "--token=your_token", + "--subdomain_host=127.0.0.1.nip.io", + "--vhost_http_port=8080", + ] + ports: + - 8080:8080 + networks: + frp: + default: + +networks: + default: + internal: + internal: true + frp: + internal: true + containers: + internal: true + driver: overlay + attachable: true diff --git a/CTFd/plugins/ctfd-whale/docs/advanced.md b/CTFd/plugins/ctfd-whale/docs/advanced.md new file mode 100644 index 0000000..596cc83 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/docs/advanced.md @@ -0,0 +1,156 @@ +# Advanced deployment + +## Note + +Please make sure that you have experienced the installation process on single node. This deployment method is *NOT* recommended on first try. + +It would be easy for you to understand what we are going to do if you have some experience in using `docker` and `frp`. + +## Goal + +The goal of this advanced deployment is to deploy the CTFd and challenge containers on seperate machines for better experiences. + +Overall, `ctfd-whale` can be decomposed into three compnents: `CTFd`, challenge containers along with frpc, and frps itself. The three components can be deployed seperately or together to satisfy different needs. + +For example, if you're in a school or an organization that has a number of high-performance dedicated server *BUT* no public IP for public access, you can refer to this tutorial. + +Here are some options: + +* deploy frps on a server with public access +* deploy challenge containers on a seperate sever by joining the server into the swarm you created earlier +* deploy challenge containers on *rootless* docker +* deploy challenge containers on a remote server with public access, *securely* + +You could achieve the first option with little effort by deploying the frps on the server and configure frpc with a different `server_addr`. +In a swarm with multiple nodes, you can configure CTFd to start challenge containers on nodes you specifies randomly. Just make sure the node `whale` controlls is a `Leader`. This is not covered in this guide. You'll find it rather simple, even if you have zero experience on docker swarm. +The [Docker docs](https://docs.docker.com/engine/security/rootless/) have a detailed introduction on how to set up a rootless docker, so it's also not covered in this guide. + +In following paragraphs, the last option is introduced. + +## Architecture + +In this tutorial, we have 2 separate machines which we'll call them `web` and `target` server later. We will deploy CTFd on `web` and challenge containers (along with frp) on `target`. + +This picture shows a brief glance. + +![architecture](imgs/arch.png) + +--- + +### Operate on `target` server + +> root user is NOT recommended +> if you want to expose your docker deployment, you might also want to use [rootless docker](https://docs.docker.com/engine/security/rootless/) + +Please read the [Docker docs](https://docs.docker.com/engine/security/protect-access/#use-tls-https-to-protect-the-docker-daemon-socket) thoroughly before continuing. + +Setup docker swarm and clone this repo as described in [installation](./install.md), then follow the steps described in the Docker docs to sign your certificates. + +> protect your certificates carefully +> one can take over the user running `dockerd` effortlessly with them +> and in most cases, the user is, unfortunately, root. + +You can now create a network for your challenges by executing + +```bash +docker network create --driver overlay --attachable challenges +``` + +Then setup frp on this machine. You might want to setup frps first: + +```bash +# change to the version you prefer +wget https://github.com/fatedier/frp/releases/download/v0.37.0/frp_0.37.0_linux_amd64.tar.gz +tar xzvf frp_0.37.0_linux_amd64.tar.gz +cd frp_0.37.0_linux_amd64 +mkdir /etc/frp +configure_frps frps.ini # refer to [installation](./install.md) +cp systemd/frps.service /etc/systemd/system +systemctl daemon-reload +systemctl enable frps +systemctl start frps +``` + +Then frpc. Frpc should be running in the same network with the challenge containers, so make sure you connect frpc to the network you just created. + +```bash +docker run -it --restart=always -d --network challenges -p 7400:7400 frankli0324/frp:frpc \ + --server_addr=host_ip:host_port \ + --server_port=7000 \ + --admin_addr=7400 \ + --admin_port=7400 \ + --admin_user=username \ + --admin_pwd=password \ + --token=your_token +``` + +You could use `docker-compose` for better experience. + +Here are some pitfalls or problems you might run into: + +#### working with `systemd` + +Copy the systemd service file to `/etc/systemd` in order to prevent it from being overwritten by future updates. + +```bash +cp /lib/systemd/system/docker.service /etc/systemd/system/docker.service +``` + +Locate `ExecStart` in the file and change it into something like this: + +```systemd +ExecStart=/usr/bin/dockerd \ + --tlsverify \ + --tlscacert=/etc/docker/certs/ca.pem \ + --tlscert=/etc/docker/certs/server-cert.pem \ + --tlskey=/etc/docker/certs/server-key.pem \ + -H tcp://0.0.0.0:2376 \ + -H unix:///var/run/docker.sock +``` + +Remember to reload `systemd` before restarting `docker.service` + +```bash +systemctl daemon-reload +systemctl restart docker +``` + +#### cloud service providers + +Most service providers provides you with a basic virus scanner in their system images, for example, AliCloud images comes with `YunDun`. You might want to disable it. The challenge containers often comes with backdoors, and is often accessed in a way cloud providers don't like (they are obviously attacks). + +#### certificate security + +Please follow the best practices when signing your certificates. If you gets used to signing both the client and server certicates on a single machine, you might run into troubles in the future. + +If you feel inconvenient, at least sign them on your personal computer, and transfer only the needed files to client/server. + +#### challenge networks and frpc + +You could create an internal network for challenges, but you have to connect frpc to a different network *with* internet in order to map the ports so that CTFd can access the admin interface. Also, make sure frps is accessible by frpc. + +### Operate on `web` server + +Map your client certificates into docker. You might want to use `docker secrets`. Remember where the files are *inside the container*. In the case which you use `docker secrets`, the directory is `/run/secrets`. + +You may also delete everything related to `frp` like `frp_network` since we are not going to run challenge containers on `web` server anymore. But if you just has one public IP for `web` server, you can leave `frps` service running. + +Then recreate your containers: + +```bash +docker-compose down # needed for removing unwanted networks +docker-compose up -d +``` + +Now you can configure CTFd accordingly. +Sample configurations: + +![whale-config1](imgs/whale-config1.png) +![whale-config2](imgs/whale-config2.png) +![whale-config3](imgs/whale-config3.png) + +refer to [installation](./install.md) for explanations. + +--- + +Now you can add a challenge to test it out. diff --git a/CTFd/plugins/ctfd-whale/docs/advanced.zh-cn.md b/CTFd/plugins/ctfd-whale/docs/advanced.zh-cn.md new file mode 100644 index 0000000..711fe7a --- /dev/null +++ b/CTFd/plugins/ctfd-whale/docs/advanced.zh-cn.md @@ -0,0 +1,268 @@ +# 高级部署 + +## 前提 + +请确认你有过单机部署的经验,不建议第一次就搞这样分布架构 + +建议有一定Docker部署及操作经验者阅读此文档 + +在进行以下步骤之前,你需要先安装好ctfd-whale插件 + +## 目的 + +分离靶机与ctfd网站服务器,CTFd通过tls api远程调用docker + +## 架构 + +两台vps + +- 一台作为安装CTFd的网站服务器,称为 `web` ,需要公网IP +- 一台作为给选手下发容器的服务器,称为 `target` ,此文档用到的服务器是有公网IP的,但如果没有,可也在 `web` 服务器用 `frps` 做转发 + +本部署方式的架构如图所示 + +![架构](imgs/arch.png) + +--- + +## 配置Docker的安全API + +参考来源:[Docker官方文档](https://docs.docker.com/engine/security/protect-access/#use-tls-https-to-protect-the-docker-daemon-socket) + + +### target服务器配置 + +建议切换到 `root` 用户操作 + +### 克隆本仓库 + +```bash +$ git clone https://github.com/frankli0324/ctfd-whale +``` + +### 开启docker swarm + +```bash +$ docker swarm init +$ docker node update --label-add "name=linux-target-1" $(docker node ls -q) +``` + +把 `name` 记住了,后面会用到 + +创建文件夹 +```bash +$ mkdir /etc/docker/certs && cd /etc/docker/certs +``` + +设置口令,需要输入2次 +```bash +$ openssl genrsa -aes256 -out ca-key.pem 4096 +``` + +用OpenSSL创建CA, 服务器, 客户端的keys +```bash +$ openssl req -new -x509 -days 365 -key ca-key.pem -sha256 -out ca.pem +``` + +生成server证书,如果你的靶机服务器没有公网IP,内网IP理论上也是可以的,只要web服务器能访问到 +```bash +$ openssl genrsa -out server-key.pem 4096 +$ openssl req -subj "/CN=" -sha256 -new -key server-key.pem -out server.csr +``` + +配置白名单 +```bash +$ echo subjectAltName = IP:0.0.0.0,IP:127.0.0.1 >> extfile.cnf +``` + +将Docker守护程序密钥的扩展使用属性设置为仅用于服务器身份验证 +```bash +$ echo extendedKeyUsage = serverAuth >> extfile.cnf +``` + +生成签名证书,此处需要输入你之前设置的口令 +```bash +$ openssl x509 -req -days 365 -sha256 -in server.csr -CA ca.pem -CAkey ca-key.pem \ +-CAcreateserial -out server-cert.pem -extfile extfile.cnf +``` + +生成客户端(web服务器)访问用的 `key.pem` +```bash +$ openssl genrsa -out key.pem 4096 +``` + +生成 `client.csr` ,此处IP与之前生成server证书的IP相同 +```bash +$ openssl req -subj "/CN=" -new -key key.pem -out client.csr +``` + +创建扩展配置文件,把密钥设置为客户端身份验证用 +```bash +$ echo extendedKeyUsage = clientAuth > extfile-client.cnf +``` + +生成 `cert.pem` +```bash +$ openssl x509 -req -days 365 -sha256 -in client.csr -CA ca.pem -CAkey ca-key.pem \ +-CAcreateserial -out cert.pem -extfile extfile-client.cnf +``` + +删掉配置文件和两个证书的签名请求,不再需要 +```bash +$ rm -v client.csr server.csr extfile.cnf extfile-client.cnf +``` + +为了防止私钥文件被更改以及被其他用户查看,修改其权限为所有者只读 +```bash +$ chmod -v 0400 ca-key.pem key.pem server-key.pem +``` + +为了防止公钥文件被更改,修改其权限为只读 +```bash +$ chmod -v 0444 ca.pem server-cert.pem cert.pem +``` + +打包公钥 +```bash +$ tar cf certs.tar *.pem +``` + +修改Docker配置,使Docker守护程序可以接受来自提供CA信任的证书的客户端的连接 + +拷贝安装包单元文件到 `/etc` ,这样就不会因为docker升级而被覆盖 +```bash +$ cp /lib/systemd/system/docker.service /etc/systemd/system/docker.service +``` + +将第 `13` 行 +``` +ExecStart=/usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock +``` +改为如下形式 +``` +ExecStart=/usr/bin/dockerd --tlsverify \ +--tlscacert=/etc/docker/certs/ca.pem \ +--tlscert=/etc/docker/certs/server-cert.pem \ +--tlskey=/etc/docker/certs/server-key.pem \ +-H tcp://0.0.0.0:2376 \ +-H unix:///var/run/docker.sock +``` + +重新加载daemon并重启docker +```bash +$ systemctl daemon-reload +$ systemctl restart docker +``` + +**注意保存好生成的密钥,任何持有密钥的用户都可以拥有target服务器的root权限** + +--- + +### Web服务器配置 + +在`root`用户下配置 + +```bash +$ cd CTFd +$ mkdir docker-certs +``` + +先把刚才打包好的公钥 `certs.tar` 复制到这台服务器上 + +然后解压 +```bash +$ tar xf certs.tar +``` + +打开 `CTFd` 项目的 `docker-compose.yml` ,在`CTFd` 服务的 `volumes` 下加一条 +``` +./docker-certs:/etc/docker/certs:ro +``` + +顺便把 `frp` 有关的**所有**配置项删掉,比如`frp_network`之类 + + +然后执行 `docker-compose up -d` + +打开`CTFd-whale`的配置网页,按照如下配置docker + +![whale-config1](imgs/whale-config1.png) + +注意事项 + +- `API URL` 一定要写成 `https://:` 的形式 +- `Swarm Nodes` 写初始化 `docker swarm` 时添加的 `lable name` +- `SSL CA Certificates` 等三个路径都是CTFd容器里的地址,不要和物理机的地址搞混了,如果你按照上一个步骤更改好了 `CTFd` 的 `docker-compose.yml` ,这里的地址照着填就好 + +对于单容器的题目,`Auto Connect Network` 中的网络地址为`_`,如果没有改动,则默认为 `whale-target_frp_containers` + +![whale-config2](imgs/whale-config2.png) + +*多容器题目配置 未测试* + +--- + +## FRP配置 + +### 添加泛解析域名,用于HTTP模式访问 + +可以是这样 +``` +*.example.com +*.sub.example.com (以此为例) +``` + +### 在target服务器上配置 + +进入 `whale-target` 文件夹 +```bash +$ cd ctfd-whale/whale-target +``` + +修改 `frp` 配置文件 +```bash +$ cp frp/frps.ini.example frp/frps.ini +$ cp frp/frpc.ini.example frp/frpc.ini +``` + +打开 `frp/frps.ini` + +- 修改 `token` 字段, 此token用于frpc与frps通信的验证 +- 此处因为frps和frpc在同一台服务器中,不改也行 +- 如果你的target服务器处于内网中,可以将 `frps` 放在 `web` 服务器中,这时token就可以长一些,比如[生成一个随机UUID](https://www.uuidgenerator.net/) +- 注意 `vhost_http_port` 与 [docker-compose.yml](/whale-target/docker-compose.yml) 里 `frps` 映射的端口相同 +- `subdomain_host` 是你做泛解析之后的域名,如果泛解析记录为`*.sub.example.com`, 则填入`sub.example.com` + + + +#### 打开 `frp/frpc.ini` + +- 修改 `token` 字段与 `frps.ini` 里的相同 + +- 修改 `admin_user` 与 `admin_pwd`字段, 用于 `frpc` 的 basic auth + +--- + +### 在WEB服务器上配置 + +打开whale的设置页面,按照如下配置参数 + +![frp配置页面](imgs/whale-config3.png) + +网页中, + +- `API URL` 需要按照 `http://user:password@ip:port` 的形式来设置 +- `Http Domain Suffix` 需要与 `frps.ini` 中的 `subdomain_host` 保持一致 +- `HTTP Port` 与 `frps.ini` 的 `vhost_http_port` 保持一致 +- `Direct Minimum Port` 与 `Direct Maximum Port` 与 `whale-target/docker-compose.yml` 中的段口范围保持一致 +- 当 API 设置成功后,whale 会自动获取`frpc.ini`的内容作为模板 + +--- + +至此,分离部署的whale应该就能用了,可以找个题目来测试一下,不过注意docker_dynamic类型的题目似乎不可以被删除,请注意不要让其他管理员把测试题公开 + +你可以用 +```bash +$ docker-compose logs +``` +来查看日志并调试,Ctrl-C退出 diff --git a/CTFd/plugins/ctfd-whale/docs/imgs/arch.png b/CTFd/plugins/ctfd-whale/docs/imgs/arch.png new file mode 100644 index 0000000000000000000000000000000000000000..f22d8278d94dd9eb10ab6edcef9e7742e37d1428 GIT binary patch literal 80104 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

1$c&d%FE)5rwJR=BJ*zq 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 literal 0 HcmV?d00001 diff --git a/CTFd/plugins/ctfd-whale/docs/imgs/whale-config2.png b/CTFd/plugins/ctfd-whale/docs/imgs/whale-config2.png new file mode 100644 index 0000000000000000000000000000000000000000..fae48aace547b48b3bb1adbcfcb0c0b62a72758d GIT binary patch literal 47065 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 bc}Mfg>9xO7DNR=Y#6kTB#yX{1k3;_#=jkT( literal 0 HcmV?d00001 diff --git a/CTFd/plugins/ctfd-whale/docs/imgs/whale-config3.png b/CTFd/plugins/ctfd-whale/docs/imgs/whale-config3.png new file mode 100644 index 0000000000000000000000000000000000000000..1dfec8902a0080e12d04ec6fe5bfa8859bfeb221 GIT binary patch literal 68153 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

ytnJj;t;RGqc42z@Ft2Q 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 literal 0 HcmV?d00001 diff --git a/CTFd/plugins/ctfd-whale/docs/install.md b/CTFd/plugins/ctfd-whale/docs/install.md new file mode 100644 index 0000000..93af127 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/docs/install.md @@ -0,0 +1,304 @@ +# Installation & Usage Guide + +## TLDR + +If you never deployed a CTFd instance before: + +```sh +curl -fsSL https://get.docker.com -o get-docker.sh +sh get-docker.sh +docker swarm init +docker node update --label-add='name=linux-1' $(docker node ls -q) + +git clone https://github.com/CTFd/CTFd --depth=1 +git clone https://github.com/frankli0324/ctfd-whale CTFd/CTFd/plugins/ctfd-whale --depth=1 +curl -fsSL https://cdn.jsdelivr.net/gh/frankli0324/ctfd-whale/docker-compose.example.yml -o CTFd/docker-compose.yml + +# make sure you have pip3 installed on your rig +pip3 install docker-compose +docker-compose -f CTFd/docker-compose.yml up -d +# wait till the containers are ready +docker-compose -f CTFd/docker-compose.yml exec ctfd python manage.py set_config whale:auto_connect_network +``` + +The commands above tries to install `docker-ce`,`python3-pip` and `docker-compose`. Make sure the following requirements are satisfied before you execute them: + +* have `curl`, `git`, `python3` and `pip` installed +* GitHub is reachable +* Docker Registry is reachable + +## Installation + +### Start from scratch + +First of all, you should initialize a docker swarm and label the nodes + +names of nodes running linux/windows should begin with `linux/windows-*` + +```bash +docker swarm init +docker node update --label-add "name=linux-1" $(docker node ls -q) +``` + +Taken advantage of the orchestration ability of `docker swarm`, `ctfd-whale` is able to distribute challenge containers to different nodes(machines). Each time a user request for a challenge container, `ctfd-whale` will randomly pick a suitable node for running the container. + +After initializing a swarm, make sure that CTFd runs as expected on your PC/server + +Note that the included compose file in CTFd 2.5.0+ starts an nginx container by default, which takes the http/80 port. make sure there's no conflicts. + +```bash +git clone https://github.com/CTFd/CTFd --depth=1 +cd CTFd # the cwd will not change throughout this guide from this line on +``` + +Change the first line of `docker-compose.yml` to support `attachable` property + +`version '2'` -> `version '3'` + +```bash +docker-compose up -d +``` + +take a look at (or port 8000) and setup CTFd + +### Configure frps + +frps could be started by docker-compose along with CTFd + +define a network for communication between frpc and frps, and create a frps service block + +```yml +services: + ... + frps: + image: glzjin/frp + restart: always + volumes: + - ./conf/frp:/conf + entrypoint: + - /usr/local/bin/frps + - -c + - /conf/frps.ini + ports: + - 10000-10100:10000-10100 # for "direct" challenges + - 8001:8001 # for "http" challenges + networks: + default: # frps ports should be mapped to host + frp_connect: + +networks: + ... + frp_connect: + driver: overlay + internal: true + ipam: + config: + - subnet: 172.1.0.0/16 +``` + +Create a folder in `conf/` called `frp` + +```bash +mkdir ./conf/frp +``` + +then create a configuration file for frps `./conf/frp/frps.ini`, and fill it with: + +```ini +[common] +# following ports must not overlap with "direct" port range defined in the compose file +bind_port = 7987 # port for frpc to connect to +vhost_http_port = 8001 # port for mapping http challenges +token = your_token +subdomain_host = node3.buuoj.cn +# hostname that's mapped to frps by some reverse proxy (or IS frps itself) +``` + +### Configure frpc + +Likewise, create a network and a service for frpc + +the network allows challenges to be accessed by frpc + +```yml +services: + ... + frpc: + image: glzjin/frp:latest + restart: always + volumes: + - ./conf/frp:/conf/ + entrypoint: + - /usr/local/bin/frpc + - -c + - /conf/frpc.ini + depends_on: + - frps #need frps to run first + networks: + frp_containers: + frp_connect: + ipv4_address: 172.1.0.3 + +networks: + ... + frp_containers: # challenge containers are attached to this network + driver: overlay + internal: true + # if challenge containers are allowed to access the internet, remove this line + attachable: true + ipam: + config: + - subnet: 172.2.0.0/16 +``` + +Likewise, create an frpc config file `./conf/frp/frpc.ini` + +```ini +[common] +token = your_token +server_addr = frps +server_port = 7897 # == frps.bind_port +admin_addr = 172.1.0.3 # refer to "Security" +admin_port = 7400 +``` + +### Verify frp configurations + +update compose stack with `docker-compose up -d` + +by executing `docker-compose logs frpc`, you should see that frpc produced following logs: + +```log +[service.go:224] login to server success, get run id [******], server udp port [******] +[service.go:109] admin server listen on ****** +``` + +by seeing this, you can confirm that frpc/frps is set up correctly. + +Note: folder layout in this guide: + +``` +CTFd/ + conf/ + nginx/ # included in CTFd 2.5.0+ + frp/ + frpc.ini + frps.ini + serve.py <- this is just an anchor +``` + +### Configure CTFd + +After finishing everything above: + +* map docker socket into CTFd container +* Attach CTFd container to frp_connect + +```yml +services: + ctfd: + ... + volumes: + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + - frpc #need frpc to run ahead + networks: + ... + frp_connect: +``` + +and then clone Whale into CTFd plugins directory (yes, finally) + +```bash +git clone https://github.com/frankli0324/CTFd-Whale CTFd/plugins/ctfd-whale --depth=1 +docker-compose build # for pip to find requirements.txt +docker-compose up -d +``` + +go to the Whale Configuration page (`/plugins/ctfd-whale/admin/settings`) + +#### Docker related configs + +`Auto Connect Network`, if you strictly followed the guide, should be `ctfd_frp_containers` + +If you're not sure about that, this command lists all networks in the current stack + +```bash +docker network ls -f "label=com.docker.compose.project=ctfd" --format "{{.Name}}" +``` + +#### frp related configs + +* `HTTP Domain Suffix` should be consistent with `subdomain_host` in frps +* `HTTP Port` with `vhost_http_port` in frps +* `Direct IP Address` should be a hostname/ip address that can be used to access frps +* `Direct Minimum Port` and `Direct Maximum Port`, you know what to do +* as long as `API URL` is filled in correctly, Whale will read the config of the connected frpc into `Frpc config template` +* setting `Frpc config template` will override contents in `frpc.ini` + +Whale should be kinda usable at this moment. + +### Configure nginx + +If you are using CTFd 2.5.0+, you can utilize the included nginx. + +remove the port mapping rule for frps vhost http port(8001) in the compose file + +If you wnat to go deeper: + +* add nginx to `default` and `internal` network +* remove CTFd from `default` and remove the mapped 8000 port + +add following server block to `./conf/nginx/nginx.conf`: + +```conf +server { + listen 80; + server_name *.node3.buuoj.cn; + location / { + proxy_pass http://frps:8001; + proxy_redirect off; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $server_name; + } +} +``` + +## Challenge Deployment + +### Standalone Containers + +Take a look at + +In one word, a `FLAG` variable will be passed into the container when it's started. You should write your own startup script (usually with bash and sed) to: + +* replace your flag with the generated flag +* remove or override the `FLAG` variable + +PLEASE create challenge images with care. + +### Grouped Containers + +"name" the challenge image with a json object, for example: + +```json +{ + "hostname": "image", +} +``` + +Whale will keep the order of the keys in the json object, and take the first image as the "main container" of a challenge. The "main container" will be mapped to frp with same rules from standalone containers + +see how grouped containers are created in the [code](utils/docker.py#L58) + +## Security + +* Please do not allow untrusted people to access the admin account. Theoretically there's an SSTI vulnerability in the config page. +* Do not set bind_addr of the frpc to `0.0.0.0` if you are following this guide. This may enable contestants to override frpc configurations. +* If you are annoyed by the complicated configuration, and you just want to set bind_addr = 0.0.0.0, remember to enable Basic Auth included in frpc, and set API URL accordingly, for example, `http://username:password@frpc:7400` + +## Advanced Deployment + +To separate the target server (for lunching instance) and CTFd web server with TLS secured docker API, please refer to [this document](advanced.md) \ No newline at end of file diff --git a/CTFd/plugins/ctfd-whale/docs/install.zh-cn.md b/CTFd/plugins/ctfd-whale/docs/install.zh-cn.md new file mode 100644 index 0000000..5d82dfc --- /dev/null +++ b/CTFd/plugins/ctfd-whale/docs/install.zh-cn.md @@ -0,0 +1,313 @@ +# 使用指南 + +## TLDR + +如果你从未部署过CTFd,你可以通过执行: + +```sh +curl -fsSL https://get.docker.com -o get-docker.sh +sh get-docker.sh --mirror Aliyun +docker swarm init +docker node update --label-add='name=linux-1' $(docker node ls -q) + +git clone https://github.com/CTFd/CTFd --depth=1 +git clone https://github.com/frankli0324/ctfd-whale CTFd/CTFd/plugins/ctfd-whale --depth=1 +curl -fsSL https://cdn.jsdelivr.net/gh/frankli0324/ctfd-whale/docker-compose.example.yml -o CTFd/docker-compose.yml + +# make sure you have pip3 installed on your rig +pip3 install docker-compose +docker-compose -f CTFd/docker-compose.yml up -d +docker-compose -f CTFd/docker-compose.yml exec ctfd python manage.py +``` + +脚本会在一台Linux机器上安装 ***docker.com版本的*** `docker-ce`,`python3-pip` 以及 `docker-compose`,请确保执行上述代码之前: + +* 安装好curl,git,python3以及pip +* 网络环境良好,能正常从GitHub克隆仓库 +* 网络环境良好,能正常从Docker Registry拖取镜像 + +## 手动安装 + +为了更好地理解ctfd-whale各个组件的作用,更充分地利用ctfd-whale,在真实使用ctfd-whale时建议用户手动、完整地从空白CTFd开始搭建一个实例。下面本文将引导你完成整个流程。 + +### 从零开始 + +首先需要初始化一个swarm集群并给节点标注名称 + +linux节点名称需要以 `linux-` 打头,windows节点则以 `windows-` 打头 + +```bash +docker swarm init +docker node update --label-add "name=linux-1" $(docker node ls -q) +``` + +`ctfd-whale`利用`docker swarm`的集群管理能力,能够将题目容器分发到不同的节点上运行。选手每次请求启动题目容器时,`ctfd-whale`都将随机选择一个合适的节点运行这个题目容器。 + +然后,我们需要确保CTFd可以正常运行。 + +注意,2.5.0+版本CTFd的 `docker-compose.yml` 中包含了一个 `nginx` 反代,占用了80端口 + +```bash +git clone https://github.com/CTFd/CTFd --depth=1 +cd CTFd # 注:以下全部内容的cwd均为此目录 +``` + +先将 `docker-compose.yml` 的第一行进行修改,以支持 `attachable` 参数 + +`version '2'` -> `version '3'` + +接着 + +```bash +docker-compose up -d +``` + +访问(或8000端口),对CTFd进行初始配置 + +### 配置frps + +frps可以直接通过docker-compose与CTFd同步启动。 + +首先在networks中添加一个网络,用于frpc与frps之间的通信,并添加frps service + +```yml +services: + ... + frps: + image: glzjin/frp + restart: always + volumes: + - ./conf/frp:/conf + entrypoint: + - /usr/local/bin/frps + - -c + - /conf/frps.ini + ports: + - 10000-10100:10000-10100 # 映射direct类型题目的端口 + - 8001:8001 # 映射http类型题目的端口 + networks: + default: # 需要将frps暴露到公网以正常访问题目容器 + frp_connect: + +networks: + ... + frp_connect: + driver: overlay + internal: true + ipam: + config: + - subnet: 172.1.0.0/16 +``` + +先创建目录 `./conf/frp` + +```bash +mkdir ./conf/frp +``` + +接着创建 `./conf/frp/frps.ini` 文件,填写: + +```ini +[common] +# 下面两个端口注意不要与direct类型题目端口范围重合 +bind_port = 7987 # frpc 连接到 frps 的端口 +vhost_http_port = 8001 # frps 映射http类型题目的端口 +token = your_token +subdomain_host = node3.buuoj.cn # 访问http题目容器的主机名 +``` + +### 配置frpc + +同样,在networks中再添加一个网络,用于frpc与题目容器之间的通信,并添加frpc service + +```yml +services: + ... + frpc: + image: glzjin/frp:latest + restart: always + volumes: + - ./conf/frp:/conf/ + entrypoint: + - /usr/local/bin/frpc + - -c + - /conf/frpc.ini + depends_on: + - frps #frps需要先成功运行 + networks: + frp_containers: # 供frpc访问题目容器 + frp_connect: # 供frpc访问frps, CTFd访问frpc + ipv4_address: 172.1.0.3 + +networks: + ... + frp_containers: + driver: overlay + internal: true # 如果允许题目容器访问外网,则可以去掉 + attachable: true + ipam: + config: + - subnet: 172.2.0.0/16 +``` + +同样,我们需要创建一个 `./conf/frp/frpc.ini` + +```ini +[common] +token = your_token +server_addr = frps +server_port = 7897 # 对应 frps 的 bind_port +admin_addr = 172.1.0.3 # 请参考“安全事项” +admin_port = 7400 +``` + +### 检查frp配置是否正确 + +此时可以执行 `docker-compose up -d` 更新compose配置 + +通过查看日志 `docker-compose logs frpc` ,应当能看到frpc产生了以下日志: + +```log +[service.go:224] login to server success, get run id [******], server udp port [******] +[service.go:109] admin server listen on ****** +``` + +说明frpc与frps皆配置正常 + +注:此例中目录结构为: + +``` +CTFd/ + conf/ + nginx # CTFd 2.5.0+中自带 + frp/ + frpc.ini + frps.ini + serve.py +``` + +### 配置CTFd + +前面的工作完成后,将本机docker的访问接口映射到CTFd所在容器内 + +并将CTFd添加到frpc所在network中(注意不是containers这个network) + +```yml +services: + ctfd: + ... + volumes: + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + - frpc #frpc需要先运行 + networks: + ... + frp_connect: +``` + +将CTFd-Whale克隆至CTFd的插件目录 + +```bash +git clone https://github.com/frankli0324/CTFd-Whale CTFd/plugins/ctfd-whale --depth=1 +docker-compose build # 需要安装依赖 +docker-compose up -d +``` + +进入Whale的配置页面( `/plugins/ctfd-whale/admin/settings` ),首先配置docker配置项 + +需要注意的是 `Auto Connect Network` ,如果按照上面的配置流程进行配置的话,应当是 `ctfd_frp_containers` + +如果不确定的话,可以通过下面的命令列出CTFd目录compose生成的所有network + +```bash +docker network ls -f "label=com.docker.compose.project=ctfd" --format "{{.Name}}" +``` + +然后检查frp配置项是否正确 + +* `HTTP Domain Suffix` 与 frps 的 `subdomain_host` 保持一致 +* `HTTP Port` 与 frps 的 `vhost_http_port` 保持一致 +* `Direct IP Address` 为能访问到 frps 相应端口(例子中为10000-10100) 的IP +* `Direct Minimum Port` 与 `Direct Maximum Port` 显然可得 +* 只要正确填写了 `API URL` ,Whale 会自动获取 frpc 的配置文件作为 `Frpc config template` +* 通过设置 `Frpc config template` 可以覆盖原有 `frpc.ini` 文件 + +至此,CTFd-Whale 已经马马虎虎可以正常使用了。 + +### 配置nginx + +如果你在使用2.5.0+版本的CTFd,那么你可以直接利用自带的nginx进行http题目的反代 + +首先去除docker-compose.yml中对frps http端口的映射(8001) +如果想贯彻到底的话,可以 + +* 为nginx添加internal与default两个network +* 去除CTFd的default network,并去除ports项 + +在 `./conf/nginx/nginx.conf` 的http block中添加以下server block + +```conf +server { + listen 80; + server_name *.node3.buuoj.cn; + location / { + proxy_pass http://frps:8001; + proxy_redirect off; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $server_name; + } +} +``` + +## 部署题目 + +### 单容器题目环境 + +请参考中的镜像进行题目镜像制作(Dockerfile编写)。总体而言,题目在启动时会向**容器**内传入名为 `FLAG` 的环境变量,你需要编写一个启动脚本(一般为bash+sed组合拳)将flag写入自己的题目中,并删除这一环境变量。 + +请出题人制作镜像时请理清思路,不要搞混容器与镜像的概念。这样既方便自己,也方便部署人员。 + +### 多容器题目环境 + +在题目镜像名处填写一个json object,即可创建一道多容器的题目 + +```json +{ + "hostname": "image", +} +``` + +Whale会保留json的key顺序,并将第一个容器作为"主容器"映射到外网,映射方式与单容器相同 +以buuoj上的swpu2019 web2为例,可以配置如下: + +```json +{ + "ss": "shadowsocks-chall", + "web": "swpu2019-web2", + ... +} +``` + +其中shadowsocks-chall的Dockerfile: + +```dockerfile +FROM shadowsocks/shadowsocks-libev +ENV PASSWORD=123456 +ENV METHOD=aes-256-cfb +``` + +> 注:由于写README的并不是buuoj管理员,故上述仅作说明用,与实际情况可能有较大出入 + +## 安全事项 + +* 后台配置中flag与domain模版理论上存在ssti(feature),请不要将管理员账号给不可信第三方 +* 由于例子中frpc并没有开启鉴权,请不要将frpc的bind_addr设置为`0.0.0.0`。这样会导致利用任何一道能发起http请求的题目都能修改frpc配置。 +* 如果出于配置复杂性考虑,题目容器能够访问frpc,请开启frpc的Basic Auth,并以 `http://username:password@frpc:7400` 的格式设置frpc API URL + +## 高级部署 + +用于下发靶机实例的服务器与运行 `CTFd` 网站的服务器分离,`CTFd-whale` 通过启用了 `TLS/SSL` 验证的 `Dockers API`进行下发容器控制 + +参见 [advanced.zh-cn.md](advanced.zh-cn.md) \ No newline at end of file diff --git a/CTFd/plugins/ctfd-whale/models.py b/CTFd/plugins/ctfd-whale/models.py new file mode 100644 index 0000000..f9263be --- /dev/null +++ b/CTFd/plugins/ctfd-whale/models.py @@ -0,0 +1,105 @@ +import random +import uuid +from datetime import datetime + +from jinja2 import Template + +from CTFd.utils import get_config +from CTFd.models import db +from CTFd.plugins.dynamic_challenges import DynamicChallenge + + +class WhaleConfig(db.Model): + key = db.Column(db.String(length=128), primary_key=True) + value = db.Column(db.Text) + + def __init__(self, key, value): + self.key = key + self.value = value + + def __repr__(self): + return "".format(self.key, self.value) + + +class WhaleRedirectTemplate(db.Model): + key = db.Column(db.String(20), primary_key=True) + frp_template = db.Column(db.Text) + access_template = db.Column(db.Text) + + def __init__(self, key, access_template, frp_template): + self.key = key + self.access_template = access_template + self.frp_template = frp_template + + def __repr__(self): + return "".format(self.key) + + +class DynamicDockerChallenge(DynamicChallenge): + __mapper_args__ = {"polymorphic_identity": "dynamic_docker"} + id = db.Column( + db.Integer, db.ForeignKey("dynamic_challenge.id", ondelete="CASCADE"), primary_key=True + ) + + memory_limit = db.Column(db.Text, default="128m") + cpu_limit = db.Column(db.Float, default=0.5) + dynamic_score = db.Column(db.Integer, default=0) + + docker_image = db.Column(db.Text, default=0) + redirect_type = db.Column(db.Text, default=0) + redirect_port = db.Column(db.Integer, default=0) + + def __init__(self, *args, **kwargs): + kwargs["initial"] = kwargs["value"] + super(DynamicDockerChallenge, self).__init__(**kwargs) + + +class WhaleContainer(db.Model): + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + user_id = db.Column(None, db.ForeignKey("users.id")) + challenge_id = db.Column(None, db.ForeignKey("challenges.id")) + start_time = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + renew_count = db.Column(db.Integer, nullable=False, default=0) + status = db.Column(db.Integer, default=1) + uuid = db.Column(db.String(256)) + port = db.Column(db.Integer, nullable=True, default=0) + flag = db.Column(db.String(128), nullable=False) + + # Relationships + user = db.relationship( + "Users", foreign_keys="WhaleContainer.user_id", lazy="select") + challenge = db.relationship( + "DynamicDockerChallenge", foreign_keys="WhaleContainer.challenge_id", lazy="select" + ) + + @property + def http_subdomain(self): + return Template(get_config( + 'whale:template_http_subdomain', '{{ container.uuid }}' + )).render(container=self) + + def __init__(self, user_id, challenge_id): + self.user_id = user_id + self.challenge_id = challenge_id + self.start_time = datetime.now() + self.renew_count = 0 + self.uuid = str(uuid.uuid4()) + self.flag = Template(get_config( + 'whale:template_chall_flag', '{{ "flag{"+uuid.uuid4()|string+"}" }}' + )).render(container=self, uuid=uuid, random=random, get_config=get_config) + + @property + def user_access(self): + return Template(WhaleRedirectTemplate.query.filter_by( + key=self.challenge.redirect_type + ).first().access_template).render(container=self, get_config=get_config) + + @property + def frp_config(self): + return Template(WhaleRedirectTemplate.query.filter_by( + key=self.challenge.redirect_type + ).first().frp_template).render(container=self, get_config=get_config) + + def __repr__(self): + return "".format(self.id, self.user_id, self.challenge_id, + self.start_time, self.renew_count) diff --git a/CTFd/plugins/ctfd-whale/requirements.txt b/CTFd/plugins/ctfd-whale/requirements.txt new file mode 100644 index 0000000..ccabdeb --- /dev/null +++ b/CTFd/plugins/ctfd-whale/requirements.txt @@ -0,0 +1,4 @@ +docker==4.1.0 +Flask-APScheduler==1.11.0 +flask-redis==0.4.0 +redis==3.3.11 \ No newline at end of file diff --git a/CTFd/plugins/ctfd-whale/templates/config/base.router.config.html b/CTFd/plugins/ctfd-whale/templates/config/base.router.config.html new file mode 100644 index 0000000..f42df5f --- /dev/null +++ b/CTFd/plugins/ctfd-whale/templates/config/base.router.config.html @@ -0,0 +1,24 @@ +

diff --git a/CTFd/plugins/ctfd-whale/templates/config/challenges.config.html b/CTFd/plugins/ctfd-whale/templates/config/challenges.config.html new file mode 100644 index 0000000..6b824e0 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/templates/config/challenges.config.html @@ -0,0 +1,25 @@ +
+ {% for config, val in { + "Subdomain Template": ("template_http_subdomain", "Controls how the subdomain of a container is generated"), + "Flag Template": ("template_chall_flag", "Controls how a flag is generated"), + }.items() %} + {% set value = get_config('whale:' + val[0]) %} +
+ + +
+ {% endfor %} + +
+ +
+
\ No newline at end of file diff --git a/CTFd/plugins/ctfd-whale/templates/config/docker.config.html b/CTFd/plugins/ctfd-whale/templates/config/docker.config.html new file mode 100644 index 0000000..a46bf1b --- /dev/null +++ b/CTFd/plugins/ctfd-whale/templates/config/docker.config.html @@ -0,0 +1,122 @@ +
+
Common
+ + Common configurations for both standalone and grouped containers +
+ {% for config, val in { + "API URL": ("docker_api_url", "Docker API to connect to"), + "Credentials": ("docker_credentials", "docker.io username and password, separated by ':'. useful for private images"), + "Swarm Nodes": ("docker_swarm_nodes", "Will pick up one from it, You should set your node with label name=windows-* or name=linux-*. Separated by commas."), + }.items() %} + {% set value = get_config('whale:' + val[0]) %} +
+ + +
+ {% endfor %} + {% set use_ssl = get_config('whale:docker_use_ssl') %} +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
Standalone Containers
+ + Typical challenges. Under most circumstances you only need to set these. +
+ {% for config, val in { + "Auto Connect Network": ("docker_auto_connect_network", "The network connected for single-containers. It's usually the same network as the frpc is in."), + "Dns Setting": ("docker_dns", "Decide which dns will be used in container network."), + }.items() %} + {% set value = get_config('whale:' + val[0]) %} +
+ + +
+ {% endfor %} +
+
Grouped Containers
+ + Designed for multi-container challenges +
+ {% for config, val in { + "Auto Connect Containers": ("docker_auto_connect_containers","Decide which container will be connected to multi-container-network automatically. Separated by commas."), + "Multi-Container Network Subnet": ("docker_subnet", "Subnet which will be used by auto created networks for multi-container challenges."), + "Multi-Container Network Subnet New Prefix": ("docker_subnet_new_prefix", "Prefix for auto created network.") + }.items() %} + {% set value = get_config('whale:' + val[0]) %} +
+ + +
+ {% endfor %} + +
+ +
+
diff --git a/CTFd/plugins/ctfd-whale/templates/config/frp.router.config.html b/CTFd/plugins/ctfd-whale/templates/config/frp.router.config.html new file mode 100644 index 0000000..2ae8adf --- /dev/null +++ b/CTFd/plugins/ctfd-whale/templates/config/frp.router.config.html @@ -0,0 +1,50 @@ +{% for config, val in { + "API URL": ("frp_api_url", "Frp API to connect to"), + "Http Domain Suffix": ("frp_http_domain_suffix", "Will be appended to the hash of a container"), + "External Http Port": ("frp_http_port", "Keep in sync with frps:vhost_http_port"), + "Direct IP Address":("frp_direct_ip_address","For direct redirect"), + "Direct Minimum Port": ("frp_direct_port_minimum", "For direct redirect (pwn challenges)"), + "Direct Maximum Port": ("frp_direct_port_maximum", "For direct redirect (pwn challenges)"), +}.items() %} + {% set value = get_config('whale:' + val[0]) %} +
+ + +
+{% endfor %} +{% set frpc_template = get_config("whale:frp_config_template", "") %} +
+ + +
+{% if frpc_template %} +
+ + +
+{% endif %} diff --git a/CTFd/plugins/ctfd-whale/templates/config/limits.config.html b/CTFd/plugins/ctfd-whale/templates/config/limits.config.html new file mode 100644 index 0000000..f07c7f3 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/templates/config/limits.config.html @@ -0,0 +1,26 @@ +
+ {% for config, val in { + "Max Container Count": ("docker_max_container_count", "The maximum number of countainers allowed on the server"), + "Max Renewal Times": ("docker_max_renew_count", "The maximum times a user is allowed to renew a container"), + "Docker Container Timeout": ("docker_timeout", "A container times out after [timeout] seconds."), + }.items() %} + {% set value = get_config('whale:' + val[0]) %} +
+ + +
+ {% endfor %} + +
+ +
+
\ No newline at end of file diff --git a/CTFd/plugins/ctfd-whale/templates/config/trp.router.config.html b/CTFd/plugins/ctfd-whale/templates/config/trp.router.config.html new file mode 100644 index 0000000..671f8ba --- /dev/null +++ b/CTFd/plugins/ctfd-whale/templates/config/trp.router.config.html @@ -0,0 +1,17 @@ +{% for config, val in { + "API URL": ("trp_api_url", "trp API to connect to"), + "Domain Suffix": ("trp_domain_suffix", "Will be used to generated the access link of a challenge"), + "Listening Port": ("trp_listening_port", "Will be used to generated the access link of a challenge"), +}.items() %} +{% set value = get_config('whale:' + val[0]) %} +
+ + +
+{% endfor %} diff --git a/CTFd/plugins/ctfd-whale/templates/containers/card.containers.html b/CTFd/plugins/ctfd-whale/templates/containers/card.containers.html new file mode 100644 index 0000000..7de8503 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/templates/containers/card.containers.html @@ -0,0 +1,57 @@ + + +
+ {% for container in containers %} +
+
+
+
+ {{ container.challenge.name | truncate(15) }} + +
+
+ {{ container.user.name | truncate(5) }} + +
+

{{ container.user_access }}

+

{{ container.flag }}

+ Time Started: {{ container.start_time }} + + + + + + +
+
+
+ {% endfor %} +
diff --git a/CTFd/plugins/ctfd-whale/templates/containers/list.containers.html b/CTFd/plugins/ctfd-whale/templates/containers/list.containers.html new file mode 100644 index 0000000..188cc2d --- /dev/null +++ b/CTFd/plugins/ctfd-whale/templates/containers/list.containers.html @@ -0,0 +1,78 @@ +
+
+ + + + + + + + {% for container in containers %} + + + + + + + + + + + + {% endfor %} + +
+
  + +
+
ID + User + Challenge + Access Method + Flag + Startup Time + Renewal Times + Delete +
+
  + +
+
+ {{ container.id }} + + + {{ container.user.name | truncate(12) }} + + + + {{ container.challenge.name }} + + + {{ container.challenge.redirect_type }}  + + + + + + + {{ container.renew_count }}  + + + +
+
+
diff --git a/CTFd/plugins/ctfd-whale/templates/whale_base.html b/CTFd/plugins/ctfd-whale/templates/whale_base.html new file mode 100644 index 0000000..98b430e --- /dev/null +++ b/CTFd/plugins/ctfd-whale/templates/whale_base.html @@ -0,0 +1,25 @@ +{% extends "admin/base.html" %} + +{% block content %} +
+
+

CTFd Whale

+
+
+
+
+
+ +
+
+
+ {% block panel %} + {% endblock %} +
+
+
+
+{% endblock %} diff --git a/CTFd/plugins/ctfd-whale/templates/whale_config.html b/CTFd/plugins/ctfd-whale/templates/whale_config.html new file mode 100644 index 0000000..5181e5b --- /dev/null +++ b/CTFd/plugins/ctfd-whale/templates/whale_config.html @@ -0,0 +1,38 @@ +{% extends "whale_base.html" %} + +{% block menu %} + + + + + +{% endblock %} + +{% block panel %} + {% include "components/errors.html" %} +
+
+
+ {% include "config/docker.config.html" %} + {% include "config/base.router.config.html" %} + {% include "config/limits.config.html" %} + {% include "config/challenges.config.html" %} +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/CTFd/plugins/ctfd-whale/templates/whale_containers.html b/CTFd/plugins/ctfd-whale/templates/whale_containers.html new file mode 100644 index 0000000..8e2bdeb --- /dev/null +++ b/CTFd/plugins/ctfd-whale/templates/whale_containers.html @@ -0,0 +1,69 @@ +{% extends "whale_base.html" %} + +{% block menu %} + + + + + + + + +{% endblock %} + +{% block panel %} + {% include "containers/" + session["view_mode"] + ".containers.html" %} +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/CTFd/plugins/ctfd-whale/utils/__init__.py b/CTFd/plugins/ctfd-whale/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/CTFd/plugins/ctfd-whale/utils/cache.py b/CTFd/plugins/ctfd-whale/utils/cache.py new file mode 100644 index 0000000..cd24700 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/utils/cache.py @@ -0,0 +1,150 @@ +import ipaddress +import warnings +from CTFd.cache import cache +from CTFd.utils import get_config +from flask_redis import FlaskRedis +from redis.exceptions import LockError + +from .db import DBContainer + + +class CacheProvider: + def __init__(self, app, *args, **kwargs): + if app.config['CACHE_TYPE'] == 'redis': + self.provider = RedisCacheProvider(app, *args, **kwargs) + elif app.config['CACHE_TYPE'] in ['filesystem', 'simple']: + if not hasattr(CacheProvider, 'cache'): + CacheProvider.cache = {} + self.provider = FilesystemCacheProvider(app, *args, **kwargs) + self.init_port_sets() + + def init_port_sets(self): + self.clear() + + containers = DBContainer.get_all_container() + used_port_list = [] + for container in containers: + if container.port != 0: + used_port_list.append(container.port) + for port in range(int(get_config("whale:frp_direct_port_minimum", 29000)), + int(get_config("whale:frp_direct_port_maximum", 28000)) + 1): + if port not in used_port_list: + self.add_available_port(port) + + from .docker import get_docker_client + client = get_docker_client() + + docker_subnet = get_config("whale:docker_subnet", "174.1.0.0/16") + docker_subnet_new_prefix = int( + get_config("whale:docker_subnet_new_prefix", "24")) + + exist_networks = [] + available_networks = [] + + for network in client.networks.list(filters={'label': 'prefix'}): + exist_networks.append(str(network.attrs['Labels']['prefix'])) + + for network in list(ipaddress.ip_network(docker_subnet).subnets(new_prefix=docker_subnet_new_prefix)): + if str(network) not in exist_networks: + available_networks.append(str(network)) + + self.add_available_network_range(*set(available_networks)) + + def __getattr__(self, name): + return self.provider.__getattribute__(name) + + +class FilesystemCacheProvider: + def __init__(self, app, *args, **kwargs): + warnings.warn( + '\n[CTFd Whale] Warning: looks like you are using filesystem cache. ' + '\nThis is for TESTING purposes only, DO NOT USE on production sites.', + RuntimeWarning + ) + self.key = 'ctfd_whale_lock-' + str(kwargs.get('user_id', 0)) + self.global_port_key = "ctfd_whale-port-set" + self.global_network_key = "ctfd_whale-network-set" + + def clear(self): + cache.set(self.global_port_key, set()) + cache.set(self.global_network_key, set()) + + def add_available_network_range(self, *ranges): + s = cache.get(self.global_network_key) + s.update(ranges) + cache.set(self.global_network_key, s) + + def get_available_network_range(self): + try: + s = cache.get(self.global_network_key) + r = s.pop() + cache.set(self.global_network_key, s) + return r + except KeyError: + return None + + def add_available_port(self, port): + s = cache.get(self.global_port_key) + s.add(port) + cache.set(self.global_port_key, s) + + def get_available_port(self): + try: + s = cache.get(self.global_port_key) + r = s.pop() + cache.set(self.global_port_key, s) + return r + except KeyError: + return None + + def acquire_lock(self): + # for testing purposes only, so no need to set this limit + return True + + def release_lock(self): + return True + + +class RedisCacheProvider(FlaskRedis): + def __init__(self, app, *args, **kwargs): + super().__init__(app) + self.key = 'ctfd_whale_lock-' + str(kwargs.get('user_id', 0)) + self.current_lock = None + self.global_port_key = "ctfd_whale-port-set" + self.global_network_key = "ctfd_whale-network-set" + + def clear(self): + self.delete(self.global_port_key) + self.delete(self.global_network_key) + + def add_available_network_range(self, *ranges): + self.sadd(self.global_network_key, *ranges) + + def get_available_network_range(self): + return self.spop(self.global_network_key).decode() + + def add_available_port(self, port): + self.sadd(self.global_port_key, str(port)) + + def get_available_port(self): + return int(self.spop(self.global_port_key)) + + def acquire_lock(self): + lock = self.lock(name=self.key, timeout=10) + + if not lock.acquire(blocking=True, blocking_timeout=2.0): + return False + + self.current_lock = lock + return True + + def release_lock(self): + if self.current_lock is None: + return False + + try: + self.current_lock.release() + + return True + except LockError: + return False diff --git a/CTFd/plugins/ctfd-whale/utils/checks.py b/CTFd/plugins/ctfd-whale/utils/checks.py new file mode 100644 index 0000000..ef1baee --- /dev/null +++ b/CTFd/plugins/ctfd-whale/utils/checks.py @@ -0,0 +1,50 @@ +from docker.errors import DockerException, TLSParameterError, APIError, requests + +from CTFd.utils import get_config + +from .docker import get_docker_client +from .routers import Router, _routers + + +class WhaleChecks: + @staticmethod + def check_docker_api(): + try: + client = get_docker_client() + except TLSParameterError as e: + return f'Docker TLS Parameters incorrect ({e})' + except DockerException as e: + return f'Docker API url incorrect ({e})' + try: + client.ping() + except (APIError, requests.RequestException): + return f'Unable to connect to Docker API, check your API connectivity' + + credentials = get_config("whale:docker_credentials") + if credentials and credentials.count(':') == 1: + try: + client.login(*credentials.split(':')) + except DockerException: + return f'Unable to log into docker registry, check your credentials' + swarm = client.info()['Swarm'] + if not swarm['ControlAvailable']: + return f'Docker swarm not available. You should initialize a swarm first. ($ docker swarm init)' + + @staticmethod + def check_frp_connection(): + router_conftype = get_config("whale:router_type", "frp") + if router_conftype not in _routers: + return "invalid router type: " + router_conftype + ok, msg = _routers[router_conftype]().check_availability() + if not ok: + return msg + + @staticmethod + def perform(): + errors = [] + for attr in dir(WhaleChecks): + if attr.startswith('check_'): + err = getattr(WhaleChecks, attr)() + if err: + errors.append(err) + return errors diff --git a/CTFd/plugins/ctfd-whale/utils/control.py b/CTFd/plugins/ctfd-whale/utils/control.py new file mode 100644 index 0000000..09c2d6b --- /dev/null +++ b/CTFd/plugins/ctfd-whale/utils/control.py @@ -0,0 +1,61 @@ +import datetime +import traceback + +from CTFd.utils import get_config +from .db import DBContainer, db +from .docker import DockerUtils +from .routers import Router + + +class ControlUtil: + @staticmethod + def try_add_container(user_id, challenge_id): + container = DBContainer.create_container_record(user_id, challenge_id) + try: + DockerUtils.add_container(container) + except Exception as e: + DBContainer.remove_container_record(user_id) + print(traceback.format_exc()) + return False, 'Docker Creation Error' + ok, msg = Router.register(container) + if not ok: + DockerUtils.remove_container(container) + DBContainer.remove_container_record(user_id) + return False, msg + return True, 'Container created' + + @staticmethod + def try_remove_container(user_id): + container = DBContainer.get_current_containers(user_id=user_id) + if not container: + return False, 'No such container' + for _ in range(3): # configurable? as "onerror_retry_cnt" + try: + ok, msg = Router.unregister(container) + if not ok: + return False, msg + DockerUtils.remove_container(container) + DBContainer.remove_container_record(user_id) + return True, 'Container destroyed' + except Exception as e: + print(traceback.format_exc()) + return False, 'Failed when destroying instance, please contact admin!' + + @staticmethod + def try_renew_container(user_id): + container = DBContainer.get_current_containers(user_id) + if not container: + return False, 'No such container' + timeout = int(get_config("whale:docker_timeout", "3600")) + container.start_time = container.start_time + \ + datetime.timedelta(seconds=timeout) + if container.start_time > datetime.datetime.now(): + container.start_time = datetime.datetime.now() + # race condition? useless maybe? + # useful when docker_timeout < poll timeout (10 seconds) + # doesn't make any sense + else: + return False, 'Invalid container' + container.renew_count += 1 + db.session.commit() + return True, 'Container Renewed' diff --git a/CTFd/plugins/ctfd-whale/utils/db.py b/CTFd/plugins/ctfd-whale/utils/db.py new file mode 100644 index 0000000..81a13ea --- /dev/null +++ b/CTFd/plugins/ctfd-whale/utils/db.py @@ -0,0 +1,104 @@ +import datetime + +from CTFd.models import db +from CTFd.utils import get_config +from ..models import WhaleContainer, WhaleRedirectTemplate + + +class DBContainer: + @staticmethod + def create_container_record(user_id, challenge_id): + container = WhaleContainer(user_id=user_id, challenge_id=challenge_id) + db.session.add(container) + db.session.commit() + + return container + + @staticmethod + def get_current_containers(user_id): + q = db.session.query(WhaleContainer) + q = q.filter(WhaleContainer.user_id == user_id) + return q.first() + + @staticmethod + def get_container_by_port(port): + q = db.session.query(WhaleContainer) + q = q.filter(WhaleContainer.port == port) + return q.first() + + @staticmethod + def remove_container_record(user_id): + q = db.session.query(WhaleContainer) + q = q.filter(WhaleContainer.user_id == user_id) + q.delete() + db.session.commit() + + @staticmethod + def get_all_expired_container(): + timeout = int(get_config("whale:docker_timeout", "3600")) + + q = db.session.query(WhaleContainer) + q = q.filter( + WhaleContainer.start_time < + datetime.datetime.now() - datetime.timedelta(seconds=timeout) + ) + return q.all() + + @staticmethod + def get_all_alive_container(): + timeout = int(get_config("whale:docker_timeout", "3600")) + + q = db.session.query(WhaleContainer) + q = q.filter( + WhaleContainer.start_time >= + datetime.datetime.now() - datetime.timedelta(seconds=timeout) + ) + return q.all() + + @staticmethod + def get_all_container(): + q = db.session.query(WhaleContainer) + return q.all() + + @staticmethod + def get_all_alive_container_page(page_start, page_end): + timeout = int(get_config("whale:docker_timeout", "3600")) + + q = db.session.query(WhaleContainer) + q = q.filter( + WhaleContainer.start_time >= + datetime.datetime.now() - datetime.timedelta(seconds=timeout) + ) + q = q.slice(page_start, page_end) + return q.all() + + @staticmethod + def get_all_alive_container_count(): + timeout = int(get_config("whale:docker_timeout", "3600")) + + q = db.session.query(WhaleContainer) + q = q.filter( + WhaleContainer.start_time >= + datetime.datetime.now() - datetime.timedelta(seconds=timeout) + ) + return q.count() + + +class DBRedirectTemplate: + @staticmethod + def get_all_templates(): + return WhaleRedirectTemplate.query.all() + + @staticmethod + def create_template(name, access_template, frp_template): + if WhaleRedirectTemplate.query.filter_by(key=name).first(): + return # already existed + db.session.add(WhaleRedirectTemplate( + name, access_template, frp_template + )) + db.session.commit() + + @staticmethod + def delete_template(name): + WhaleRedirectTemplate.query.filter_by(key=name).delete() + db.session.commit() diff --git a/CTFd/plugins/ctfd-whale/utils/docker.py b/CTFd/plugins/ctfd-whale/utils/docker.py new file mode 100644 index 0000000..2fe1b42 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/utils/docker.py @@ -0,0 +1,202 @@ +import json +import random +import uuid +from collections import OrderedDict + +import docker +from flask import current_app + +from CTFd.utils import get_config + +from .cache import CacheProvider +from .exceptions import WhaleError + + +def get_docker_client(): + if get_config("whale:docker_use_ssl", False): + tls_config = docker.tls.TLSConfig( + verify=True, + ca_cert=get_config("whale:docker_ssl_ca_cert") or None, + client_cert=( + get_config("whale:docker_ssl_client_cert"), + get_config("whale:docker_ssl_client_key") + ), + ) + return docker.DockerClient( + base_url=get_config("whale:docker_api_url"), + tls=tls_config, + ) + else: + return docker.DockerClient(base_url=get_config("whale:docker_api_url")) + + +class DockerUtils: + @staticmethod + def init(): + try: + DockerUtils.client = get_docker_client() + # docker-py is thread safe: https://github.com/docker/docker-py/issues/619 + except Exception: + raise WhaleError( + 'Docker Connection Error\n' + 'Please ensure the docker api url (first config item) is correct\n' + 'if you are using unix:///var/run/docker.sock, check if the socket is correctly mapped' + ) + credentials = get_config("whale:docker_credentials") + if credentials and credentials.count(':') == 1: + try: + DockerUtils.client.login(*credentials.split(':')) + except Exception: + raise WhaleError('docker.io failed to login, check your credentials') + + @staticmethod + def add_container(container): + if container.challenge.docker_image.startswith("{"): + DockerUtils._create_grouped_container(DockerUtils.client, container) + else: + DockerUtils._create_standalone_container(DockerUtils.client, container) + + @staticmethod + def _create_standalone_container(client, container): + dns = get_config("whale:docker_dns", "").split(",") + node = DockerUtils.choose_node( + container.challenge.docker_image, + get_config("whale:docker_swarm_nodes", "").split(",") + ) + + client.services.create( + image=container.challenge.docker_image, + name=f'{container.user_id}-{container.uuid}', + env={'FLAG': container.flag}, dns_config=docker.types.DNSConfig(nameservers=dns), + networks=[get_config("whale:docker_auto_connect_network", "ctfd_frp-containers")], + resources=docker.types.Resources( + mem_limit=DockerUtils.convert_readable_text( + container.challenge.memory_limit), + cpu_limit=int(container.challenge.cpu_limit * 1e9) + ), + labels={ + 'whale_id': f'{container.user_id}-{container.uuid}' + }, # for container deletion + constraints=['node.labels.name==' + node], + endpoint_spec=docker.types.EndpointSpec(mode='dnsrr', ports={}) + ) + + @staticmethod + def _create_grouped_container(client, container): + range_prefix = CacheProvider(app=current_app).get_available_network_range() + + ipam_pool = docker.types.IPAMPool(subnet=range_prefix) + ipam_config = docker.types.IPAMConfig( + driver='default', pool_configs=[ipam_pool]) + network_name = f'{container.user_id}-{container.uuid}' + network = client.networks.create( + network_name, internal=True, + ipam=ipam_config, attachable=True, + labels={'prefix': range_prefix}, + driver="overlay", scope="swarm" + ) + + dns = [] + containers = get_config("whale:docker_auto_connect_containers", "").split(",") + for c in containers: + if not c: + continue + network.connect(c) + if "dns" in c: + network.reload() + for name in network.attrs['Containers']: + if network.attrs['Containers'][name]['Name'] == c: + dns.append(network.attrs['Containers'][name]['IPv4Address'].split('/')[0]) + + has_processed_main = False + try: + images = json.loads( + container.challenge.docker_image, + object_pairs_hook=OrderedDict + ) + except json.JSONDecodeError: + raise WhaleError( + "Challenge Image Parse Error\n" + "plase check the challenge image string" + ) + for name, image in images.items(): + if has_processed_main: + container_name = f'{container.user_id}-{uuid.uuid4()}' + else: + container_name = f'{container.user_id}-{container.uuid}' + node = DockerUtils.choose_node(image, get_config("whale:docker_swarm_nodes", "").split(",")) + has_processed_main = True + client.services.create( + image=image, name=container_name, networks=[ + docker.types.NetworkAttachmentConfig(network_name, aliases=[name]) + ], + env={'FLAG': container.flag}, + dns_config=docker.types.DNSConfig(nameservers=dns), + resources=docker.types.Resources( + mem_limit=DockerUtils.convert_readable_text( + container.challenge.memory_limit + ), + cpu_limit=int(container.challenge.cpu_limit * 1e9)), + labels={ + 'whale_id': f'{container.user_id}-{container.uuid}' + }, # for container deletion + hostname=name, constraints=['node.labels.name==' + node], + endpoint_spec=docker.types.EndpointSpec(mode='dnsrr', ports={}) + ) + + @staticmethod + def remove_container(container): + whale_id = f'{container.user_id}-{container.uuid}' + + for s in DockerUtils.client.services.list(filters={'label': f'whale_id={whale_id}'}): + s.remove() + + networks = DockerUtils.client.networks.list(names=[whale_id]) + if len(networks) > 0: # is grouped containers + auto_containers = get_config("whale:docker_auto_connect_containers", "").split(",") + redis_util = CacheProvider(app=current_app) + for network in networks: + for container in auto_containers: + try: + network.disconnect(container, force=True) + except Exception: + pass + redis_util.add_available_network_range(network.attrs['Labels']['prefix']) + network.remove() + + @staticmethod + def convert_readable_text(text): + lower_text = text.lower() + + if lower_text.endswith("k"): + return int(text[:-1]) * 1024 + + if lower_text.endswith("m"): + return int(text[:-1]) * 1024 * 1024 + + if lower_text.endswith("g"): + return int(text[:-1]) * 1024 * 1024 * 1024 + + return 0 + + @staticmethod + def choose_node(image, nodes): + win_nodes = [] + linux_nodes = [] + for node in nodes: + if node.startswith("windows"): + win_nodes.append(node) + else: + linux_nodes.append(node) + try: + tag = image.split(":")[1:] + if len(tag) and tag[0].startswith("windows"): + return random.choice(win_nodes) + return random.choice(linux_nodes) + except IndexError: + raise WhaleError( + 'No Suitable Nodes.\n' + 'If you are using Whale for the first time, \n' + 'Please Setup Swarm Nodes Correctly and Lable Them with\n' + 'docker node update --label-add "name=linux-1" $(docker node ls -q)' + ) diff --git a/CTFd/plugins/ctfd-whale/utils/exceptions.py b/CTFd/plugins/ctfd-whale/utils/exceptions.py new file mode 100644 index 0000000..08886f7 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/utils/exceptions.py @@ -0,0 +1,8 @@ +class WhaleError(Exception): + def __init__(self, msg): + super().__init__(msg) + self.message = msg + + +class WhaleWarning(Warning): + pass diff --git a/CTFd/plugins/ctfd-whale/utils/routers/__init__.py b/CTFd/plugins/ctfd-whale/utils/routers/__init__.py new file mode 100644 index 0000000..96f0174 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/utils/routers/__init__.py @@ -0,0 +1,34 @@ +from CTFd.utils import get_config + +from .frp import FrpRouter +from .trp import TrpRouter + +_routers = { + 'frp': FrpRouter, + 'trp': TrpRouter, +} + + +def instanciate(cls): + return cls() + + +@instanciate +class Router: + _name = '' + _router = None + + def __getattr__(self, name: str): + router_conftype = get_config("whale:router_type", "frp") + if Router._name != router_conftype: + Router._router = _routers[router_conftype]() + Router._name = router_conftype + return getattr(Router._router, name) + + @staticmethod + def reset(): + Router._name = '' + Router._router = None + + +__all__ = ["Router"] diff --git a/CTFd/plugins/ctfd-whale/utils/routers/base.py b/CTFd/plugins/ctfd-whale/utils/routers/base.py new file mode 100644 index 0000000..eba7118 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/utils/routers/base.py @@ -0,0 +1,25 @@ +import typing + +from ...models import WhaleContainer + + +class BaseRouter: + name = None + + def __init__(self): + pass + + def access(self, container: WhaleContainer): + pass + + def register(self, container: WhaleContainer): + pass + + def unregister(self, container: WhaleContainer): + pass + + def reload(self): + pass + + def check_availability(self) -> typing.Tuple[bool, str]: + pass diff --git a/CTFd/plugins/ctfd-whale/utils/routers/frp.py b/CTFd/plugins/ctfd-whale/utils/routers/frp.py new file mode 100644 index 0000000..08ba694 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/utils/routers/frp.py @@ -0,0 +1,132 @@ +import warnings + +from flask import current_app +from requests import session, RequestException + +from CTFd.models import db +from CTFd.utils import get_config, set_config, logging + +from .base import BaseRouter +from ..cache import CacheProvider +from ..db import DBContainer +from ..exceptions import WhaleError, WhaleWarning +from ...models import WhaleContainer + + +class FrpRouter(BaseRouter): + name = "frp" + types = { + 'direct': 'tcp', + 'http': 'http', + } + + class FrpRule: + def __init__(self, name, config): + self.name = name + self.config = config + + def __str__(self) -> str: + return f'[{self.name}]\n' + '\n'.join(f'{k} = {v}' for k, v in self.config.items()) + + def __init__(self): + super().__init__() + self.ses = session() + self.url = get_config("whale:frp_api_url").rstrip("/") + self.common = '' + try: + CacheProvider(app=current_app).init_port_sets() + except Exception: + warnings.warn( + "cache initialization failed", + WhaleWarning + ) + + def reload(self, exclude=None): + rules = [] + for container in DBContainer.get_all_alive_container(): + if container.uuid == exclude: + continue + name = f'{container.challenge.redirect_type}_{container.user_id}_{container.uuid}' + config = { + 'type': self.types[container.challenge.redirect_type], + 'local_ip': f'{container.user_id}-{container.uuid}', + 'local_port': container.challenge.redirect_port, + 'use_compression': 'true', + } + if config['type'] == 'http': + config['subdomain'] = container.http_subdomain + elif config['type'] == 'tcp': + config['remote_port'] = container.port + rules.append(self.FrpRule(name, config)) + + try: + if not self.common: + common = get_config("whale:frp_config_template", '') + if '[common]' in common: + self.common = common + else: + remote = self.ses.get(f'{self.url}/api/config') + assert remote.status_code == 200 + set_config("whale:frp_config_template", remote.text) + self.common = remote.text + config = self.common + '\n' + '\n'.join(str(r) for r in rules) + assert self.ses.put( + f'{self.url}/api/config', config, timeout=5 + ).status_code == 200 + assert self.ses.get( + f'{self.url}/api/reload', timeout=5 + ).status_code == 200 + except (RequestException, AssertionError) as e: + raise WhaleError( + '\nfrpc request failed\n' + + (f'{e}\n' if str(e) else '') + + 'please check the frp related configs' + ) from None + + def access(self, container: WhaleContainer): + if container.challenge.redirect_type == 'direct': + return f'nc {get_config("whale:frp_direct_ip_address", "127.0.0.1")} {container.port}' + elif container.challenge.redirect_type == 'http': + host = get_config("whale:frp_http_domain_suffix", "") + port = get_config("whale:frp_http_port", "80") + host += f':{port}' if port != 80 else '' + return f'Link to the Challenge' + return '' + + def register(self, container: WhaleContainer): + if container.challenge.redirect_type == 'direct': + if not container.port: + port = CacheProvider(app=current_app).get_available_port() + if not port: + return False, 'No available ports. Please wait for a few minutes.' + container.port = port + db.session.commit() + elif container.challenge.redirect_type == 'http': + # config['subdomain'] = container.http_subdomain + pass + self.reload() + return True, 'success' + + def unregister(self, container: WhaleContainer): + if container.challenge.redirect_type == 'direct': + try: + redis_util = CacheProvider(app=current_app) + redis_util.add_available_port(container.port) + except Exception as e: + logging.log( + 'whale', 'Error deleting port from cache', + name=container.user.name, + challenge_id=container.challenge_id, + ) + return False, 'Error deleting port from cache' + self.reload(exclude=container.uuid) + return True, 'success' + + def check_availability(self): + try: + resp = self.ses.get(f'{self.url}/api/status', timeout=2.0) + except RequestException as e: + return False, 'Unable to access frpc admin api' + if resp.status_code == 401: + return False, 'frpc admin api unauthorized' + return True, 'Available' diff --git a/CTFd/plugins/ctfd-whale/utils/routers/trp.py b/CTFd/plugins/ctfd-whale/utils/routers/trp.py new file mode 100644 index 0000000..9567b07 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/utils/routers/trp.py @@ -0,0 +1,69 @@ +import traceback +from requests import session, RequestException, HTTPError + +from CTFd.utils import get_config +from .base import BaseRouter +from ..db import DBContainer, WhaleContainer + + +class TrpRouter(BaseRouter): + name = "trp" + + def __init__(self): + super().__init__() + self.ses = session() + self.url = get_config('whale:trp_api_url', '').rstrip("/") + self.common = '' + for container in DBContainer.get_all_alive_container(): + self.register(container) + + @staticmethod + def get_domain(container: WhaleContainer): + domain = get_config('whale:trp_domain_suffix', '127.0.0.1.nip.io').lstrip('.') + domain = f'{container.uuid}.{domain}' + return domain + + def access(self, container: WhaleContainer): + ch_type = container.challenge.redirect_type + domain = self.get_domain(container) + port = get_config('whale:trp_listening_port', 1443) + if ch_type == 'direct': + return f'from pwn import *
remote("{domain}", {port}, ssl=True).interactive()' + elif ch_type == 'http': + return f'https://{domain}' + (f':{port}' if port != 443 else '') + else: + return f'[ssl] {domain} {port}' + + def register(self, container: WhaleContainer): + try: + resp = self.ses.post(f'{self.url}/rule/{self.get_domain(container)}', json={ + 'target': f'{container.user_id}-{container.uuid}:{container.challenge.redirect_port}', + 'source': None, + }) + resp.raise_for_status() + return True, 'success' + except HTTPError as e: + return False, e.response.text + except RequestException as e: + print(traceback.format_exc()) + return False, 'unable to access trp Api' + + def unregister(self, container: WhaleContainer): + try: + resp = self.ses.delete(f'{self.url}/rule/{self.get_domain(container)}') + resp.raise_for_status() + return True, 'success' + except HTTPError as e: + return False, e.response.text + except RequestException as e: + print(traceback.format_exc()) + return False, 'unable to access trp Api' + + def check_availability(self): + try: + resp = self.ses.get(f'{self.url}/rules').json() + except RequestException as e: + return False, 'Unable to access trp admin api' + except Exception as e: + return False, 'Unknown trp error' + return True, 'Available' diff --git a/CTFd/plugins/ctfd-whale/utils/setup.py b/CTFd/plugins/ctfd-whale/utils/setup.py new file mode 100644 index 0000000..ca85e62 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/utils/setup.py @@ -0,0 +1,60 @@ +from CTFd.utils import set_config + +from ..models import WhaleRedirectTemplate, db + + +def setup_default_configs(): + for key, val in { + 'setup': 'true', + 'docker_api_url': 'unix:///var/run/docker.sock', + 'docker_credentials': '', + 'docker_dns': '127.0.0.1', + 'docker_max_container_count': '100', + 'docker_max_renew_count': '5', + 'docker_subnet': '174.1.0.0/16', + 'docker_subnet_new_prefix': '24', + 'docker_swarm_nodes': 'linux-1', + 'docker_timeout': '3600', + 'frp_api_url': 'http://frpc:7400', + 'frp_http_port': '8080', + 'frp_http_domain_suffix': '127.0.0.1.nip.io', + 'frp_direct_port_maximum': '10100', + 'frp_direct_port_minimum': '10000', + 'template_http_subdomain': '{{ container.uuid }}', + 'template_chall_flag': '{{ "flag{"+uuid.uuid4()|string+"}" }}', + }.items(): + set_config('whale:' + key, val) + db.session.add(WhaleRedirectTemplate( + 'http', + 'http://{{ container.http_subdomain }}.' + '{{ get_config("whale:frp_http_domain_suffix", "") }}' + '{% if get_config("whale:frp_http_port", "80") != 80 %}:{{ get_config("whale:frp_http_port") }}{% endif %}/', + ''' +[http_{{ container.user_id|string }}-{{ container.uuid }}] +type = http +local_ip = {{ container.user_id|string }}-{{ container.uuid }} +local_port = {{ container.challenge.redirect_port }} +subdomain = {{ container.http_subdomain }} +use_compression = true +''' + )) + db.session.add(WhaleRedirectTemplate( + 'direct', + 'nc {{ get_config("whale:frp_direct_ip_address", "127.0.0.1") }} {{ container.port }}', + ''' +[direct_{{ container.user_id|string }}-{{ container.uuid }}] +type = tcp +local_ip = {{ container.user_id|string }}-{{ container.uuid }} +local_port = {{ container.challenge.redirect_port }} +remote_port = {{ container.port }} +use_compression = true + +[direct_{{ container.user_id|string }}-{{ container.uuid }}_udp] +type = udp +local_ip = {{ container.user_id|string }}-{{ container.uuid }} +local_port = {{ container.challenge.redirect_port }} +remote_port = {{ container.port }} +use_compression = true +''' + )) + db.session.commit() diff --git a/CTFd/plugins/dynamic_challenges/.gitignore b/CTFd/plugins/dynamic_challenges/.gitignore new file mode 100644 index 0000000..43ae0e2 --- /dev/null +++ b/CTFd/plugins/dynamic_challenges/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.py[cod] diff --git a/CTFd/plugins/dynamic_challenges/README.md b/CTFd/plugins/dynamic_challenges/README.md new file mode 100644 index 0000000..6ffa28c --- /dev/null +++ b/CTFd/plugins/dynamic_challenges/README.md @@ -0,0 +1,54 @@ +# Dynamic Value Challenges for CTFd + +It's becoming commonplace in CTF to see challenges whose point values decrease +after each solve. + +This CTFd plugin creates a dynamic challenge type which implements this +behavior. Each dynamic challenge starts with an initial point value and then +each solve will decrease the value of the challenge until a minimum point value. + +By reducing the value of the challenge on each solve, all users who have previously +solved the challenge will have lowered scores. Thus an easier and more solved +challenge will naturally have a lower point value than a harder and less solved +challenge. + +Within CTFd you are free to mix and match regular and dynamic challenges. + +The current implementation requires the challenge to keep track of three values: + +- Initial - The original point valuation +- Decay - The amount of solves before the challenge will be at the minimum +- Minimum - The lowest possible point valuation + +The value decay logic is implemented with the following math: + + + +![](https://raw.githubusercontent.com/CTFd/DynamicValueChallenge/master/function.png) + +or in pseudo code: + +``` +value = (((minimum - initial)/(decay**2)) * (solve_count**2)) + initial +value = math.ceil(value) +``` + +If the number generated is lower than the minimum, the minimum is chosen +instead. + +A parabolic function is chosen instead of an exponential or logarithmic decay function +so that higher valued challenges have a slower drop from their initial value. + +# Installation + +**REQUIRES: CTFd >= v1.2.0** + +1. Clone this repository to `CTFd/plugins`. It is important that the folder is + named `DynamicValueChallenge` so CTFd can serve the files in the `assets` + directory. diff --git a/CTFd/plugins/dynamic_challenges/__init__.py b/CTFd/plugins/dynamic_challenges/__init__.py new file mode 100644 index 0000000..45f51e3 --- /dev/null +++ b/CTFd/plugins/dynamic_challenges/__init__.py @@ -0,0 +1,154 @@ +from flask import Blueprint + +from CTFd.exceptions.challenges import ( + ChallengeCreateException, + ChallengeUpdateException, +) +from CTFd.models import Challenges, db +from CTFd.plugins import register_plugin_assets_directory +from CTFd.plugins.challenges import CHALLENGE_CLASSES, BaseChallenge +from CTFd.plugins.dynamic_challenges.decay import DECAY_FUNCTIONS, logarithmic +from CTFd.plugins.migrations import upgrade + + +class DynamicChallenge(Challenges): + __mapper_args__ = {"polymorphic_identity": "dynamic"} + id = db.Column( + db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE"), primary_key=True + ) + dynamic_initial = db.Column(db.Integer, default=0) + dynamic_minimum = db.Column(db.Integer, default=0) + dynamic_decay = db.Column(db.Integer, default=0) + dynamic_function = db.Column(db.String(32), default="logarithmic") + + @property + def initial(self): + return self.dynamic_initial + + @initial.setter + def initial(self, initial_value): + self.dynamic_initial = initial_value + + @property + def minimum(self): + return self.dynamic_minimum + + @minimum.setter + def minimum(self, minimum_value): + self.dynamic_minimum = minimum_value + + @property + def decay(self): + return self.dynamic_decay + + @decay.setter + def decay(self, decay_value): + self.dynamic_decay = decay_value + + @property + def function(self): + return self.dynamic_function + + @function.setter + def function(self, function_value): + self.dynamic_function = function_value + + def __init__(self, *args, **kwargs): + super(DynamicChallenge, self).__init__(**kwargs) + try: + self.value = kwargs["initial"] + except KeyError: + raise ChallengeCreateException("Missing initial value for challenge") + + +class DynamicValueChallenge(BaseChallenge): + id = "dynamic" # Unique identifier used to register challenges + name = "dynamic" # Name of a challenge type + templates = ( + { # Handlebars templates used for each aspect of challenge editing & viewing + "create": "/plugins/dynamic_challenges/assets/create.html", + "update": "/plugins/dynamic_challenges/assets/update.html", + "view": "/plugins/dynamic_challenges/assets/view.html", + } + ) + scripts = { # Scripts that are loaded when a template is loaded + "create": "/plugins/dynamic_challenges/assets/create.js", + "update": "/plugins/dynamic_challenges/assets/update.js", + "view": "/plugins/dynamic_challenges/assets/view.js", + } + # Route at which files are accessible. This must be registered using register_plugin_assets_directory() + route = "/plugins/dynamic_challenges/assets/" + # Blueprint used to access the static_folder directory. + blueprint = Blueprint( + "dynamic_challenges", + __name__, + template_folder="templates", + static_folder="assets", + ) + challenge_model = DynamicChallenge + + @classmethod + def calculate_value(cls, challenge): + f = DECAY_FUNCTIONS.get(challenge.function, logarithmic) + value = f(challenge) + + challenge.value = value + db.session.commit() + return challenge + + @classmethod + def read(cls, challenge): + """ + This method is in used to access the data of a challenge in a format processable by the front end. + + :param challenge: + :return: Challenge object, data dictionary to be returned to the user + """ + challenge = DynamicChallenge.query.filter_by(id=challenge.id).first() + data = super().read(challenge) + data.update( + { + "initial": challenge.initial, + "decay": challenge.decay, + "minimum": challenge.minimum, + "function": challenge.function, + } + ) + return data + + @classmethod + def update(cls, challenge, request): + """ + This method is used to update the information associated with a challenge. This should be kept strictly to the + Challenges table and any child tables. + + :param challenge: + :param request: + :return: + """ + data = request.form or request.get_json() + + for attr, value in data.items(): + # We need to set these to floats so that the next operations don't operate on strings + if attr in ("initial", "minimum", "decay"): + try: + value = float(value) + except (ValueError, TypeError): + raise ChallengeUpdateException(f"Invalid input for '{attr}'") + setattr(challenge, attr, value) + + return DynamicValueChallenge.calculate_value(challenge) + + @classmethod + def solve(cls, user, team, challenge, request): + super().solve(user, team, challenge, request) + + DynamicValueChallenge.calculate_value(challenge) + + +def load(app): + upgrade(plugin_name="dynamic_challenges") + CHALLENGE_CLASSES["dynamic"] = DynamicValueChallenge + register_plugin_assets_directory( + app, base_path="/plugins/dynamic_challenges/assets/" + ) diff --git a/CTFd/plugins/dynamic_challenges/assets/create.html b/CTFd/plugins/dynamic_challenges/assets/create.html new file mode 100644 index 0000000..72a7624 --- /dev/null +++ b/CTFd/plugins/dynamic_challenges/assets/create.html @@ -0,0 +1,63 @@ +{% extends "admin/challenges/create.html" %} + +{% block header %} + +{% endblock %} + + +{% block value %} +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+{% endblock %} + +{% block type %} + +{% endblock %} \ No newline at end of file diff --git a/CTFd/plugins/dynamic_challenges/assets/create.js b/CTFd/plugins/dynamic_challenges/assets/create.js new file mode 100644 index 0000000..bcfe9c3 --- /dev/null +++ b/CTFd/plugins/dynamic_challenges/assets/create.js @@ -0,0 +1,4 @@ +CTFd.plugin.run((_CTFd) => { + const $ = _CTFd.lib.$ + const md = _CTFd.lib.markdown() +}) diff --git a/CTFd/plugins/dynamic_challenges/assets/update.html b/CTFd/plugins/dynamic_challenges/assets/update.html new file mode 100644 index 0000000..9c92afa --- /dev/null +++ b/CTFd/plugins/dynamic_challenges/assets/update.html @@ -0,0 +1,71 @@ +{% extends "admin/challenges/update.html" %} + +{% block value %} +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+{% endblock %} + +{% block function %} +{% endblock %} + +{% block initial %} +{% endblock %} + +{% block decay %} +{% endblock %} + +{% block minimum %} +{% endblock %} \ No newline at end of file diff --git a/CTFd/plugins/dynamic_challenges/assets/update.js b/CTFd/plugins/dynamic_challenges/assets/update.js new file mode 100644 index 0000000..e69de29 diff --git a/CTFd/plugins/dynamic_challenges/assets/view.html b/CTFd/plugins/dynamic_challenges/assets/view.html new file mode 100644 index 0000000..ab623af --- /dev/null +++ b/CTFd/plugins/dynamic_challenges/assets/view.html @@ -0,0 +1 @@ +{% extends "challenge.html" %} \ No newline at end of file diff --git a/CTFd/plugins/dynamic_challenges/assets/view.js b/CTFd/plugins/dynamic_challenges/assets/view.js new file mode 100644 index 0000000..b03a28e --- /dev/null +++ b/CTFd/plugins/dynamic_challenges/assets/view.js @@ -0,0 +1,37 @@ +CTFd._internal.challenge.data = undefined; + +// TODO: Remove in CTFd v4.0 +CTFd._internal.challenge.renderer = null; + +CTFd._internal.challenge.preRender = function() {}; + +// TODO: Remove in CTFd v4.0 +CTFd._internal.challenge.render = null; + +CTFd._internal.challenge.postRender = function() {}; + +CTFd._internal.challenge.submit = function(preview) { + var challenge_id = parseInt(CTFd.lib.$("#challenge-id").val()); + var submission = CTFd.lib.$("#challenge-input").val(); + + var body = { + challenge_id: challenge_id, + submission: submission + }; + var params = {}; + if (preview) { + params["preview"] = true; + } + + return CTFd.api.post_challenge_attempt(params, body).then(function(response) { + if (response.status === 429) { + // User was ratelimited but process response + return response; + } + if (response.status === 403) { + // User is not logged in or CTF is paused. + return response; + } + return response; + }); +}; diff --git a/CTFd/plugins/dynamic_challenges/decay.py b/CTFd/plugins/dynamic_challenges/decay.py new file mode 100644 index 0000000..8db468f --- /dev/null +++ b/CTFd/plugins/dynamic_challenges/decay.py @@ -0,0 +1,75 @@ +from __future__ import division # Use floating point for math calculations + +import math + +from CTFd.models import Solves +from CTFd.utils.modes import get_model + + +def get_solve_count(challenge): + Model = get_model() + + solve_count = ( + Solves.query.join(Model, Solves.account_id == Model.id) + .filter( + Solves.challenge_id == challenge.id, + Model.hidden == False, + Model.banned == False, + ) + .count() + ) + return solve_count + + +def linear(challenge): + solve_count = get_solve_count(challenge) + + # If the solve count is 0 we shouldn't manipulate the solve count to + # let the math update back to normal + if solve_count != 0: + # We subtract -1 to allow the first solver to get max point value + solve_count -= 1 + + value = challenge.initial - (challenge.decay * solve_count) + + value = math.ceil(value) + + if value < challenge.minimum: + value = challenge.minimum + + return value + + +def logarithmic(challenge): + solve_count = get_solve_count(challenge) + + # If the solve count is 0 we shouldn't manipulate the solve count to + # let the math update back to normal + if solve_count != 0: + # We subtract -1 to allow the first solver to get max point value + solve_count -= 1 + + # Handle situations where admins have entered a 0 decay + # This is invalid as it can cause a division by zero + if challenge.decay == 0: + challenge.decay = 1 + + # It is important that this calculation takes into account floats. + # Hence this file uses from __future__ import division + value = ( + ((challenge.minimum - challenge.initial) / (challenge.decay**2)) + * (solve_count**2) + ) + challenge.initial + + value = math.ceil(value) + + if value < challenge.minimum: + value = challenge.minimum + + return value + + +DECAY_FUNCTIONS = { + "linear": linear, + "logarithmic": logarithmic, +} diff --git a/CTFd/plugins/dynamic_challenges/function.png b/CTFd/plugins/dynamic_challenges/function.png new file mode 100644 index 0000000000000000000000000000000000000000..b35e45b03fd693273d97924ebe19963ac3ae7a4f GIT binary patch literal 2460 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 literal 0 HcmV?d00001 diff --git a/CTFd/plugins/dynamic_challenges/migrations/93284ed9c099_add_dynamic_prefix_to_dynamic_challenge_.py b/CTFd/plugins/dynamic_challenges/migrations/93284ed9c099_add_dynamic_prefix_to_dynamic_challenge_.py new file mode 100644 index 0000000..109de68 --- /dev/null +++ b/CTFd/plugins/dynamic_challenges/migrations/93284ed9c099_add_dynamic_prefix_to_dynamic_challenge_.py @@ -0,0 +1,142 @@ +"""Add dynamic prefix to dynamic_challenge table + +Revision ID: 93284ed9c099 +Revises: eb68f277ab61 +Create Date: 2025-10-10 02:07:16.055798 + +""" +import sqlalchemy as sa + +from CTFd.plugins.migrations import get_columns_for_table + +# revision identifiers, used by Alembic. +revision = "93284ed9c099" +down_revision = "eb68f277ab61" +branch_labels = None +depends_on = None + + +def upgrade(op=None): + columns = get_columns_for_table( + op=op, table_name="dynamic_challenge", names_only=True + ) + # Add new columns with dynamic_ prefix + if "dynamic_initial" not in columns: + op.add_column( + "dynamic_challenge", + sa.Column("dynamic_initial", sa.Integer(), nullable=True), + ) + if "dynamic_minimum" not in columns: + op.add_column( + "dynamic_challenge", + sa.Column("dynamic_minimum", sa.Integer(), nullable=True), + ) + if "dynamic_decay" not in columns: + op.add_column( + "dynamic_challenge", sa.Column("dynamic_decay", sa.Integer(), nullable=True) + ) + if "dynamic_function" not in columns: + op.add_column( + "dynamic_challenge", + sa.Column("dynamic_function", sa.String(length=32), nullable=True), + ) + + # Copy data from old columns to new columns + connection = op.get_bind() + url = str(connection.engine.url) + if url.startswith("postgres"): + connection.execute( + sa.text( + """ + UPDATE dynamic_challenge + SET dynamic_initial = initial, + dynamic_minimum = minimum, + dynamic_decay = decay, + dynamic_function = function + """ + ) + ) + else: + connection.execute( + sa.text( + """ + UPDATE dynamic_challenge + SET dynamic_initial = initial, + dynamic_minimum = minimum, + dynamic_decay = decay, + dynamic_function = `function` + """ + ) + ) + + # Drop old columns + if "minimum" in columns: + op.drop_column("dynamic_challenge", "minimum") + if "initial" in columns: + op.drop_column("dynamic_challenge", "initial") + if "function" in columns: + op.drop_column("dynamic_challenge", "function") + if "decay" in columns: + op.drop_column("dynamic_challenge", "decay") + + +def downgrade(op=None): + columns = get_columns_for_table( + op=op, table_name="dynamic_challenge", names_only=True + ) + # Add old columns back + if "decay" not in columns: + op.add_column( + "dynamic_challenge", sa.Column("decay", sa.Integer(), nullable=True) + ) + if "function" not in columns: + op.add_column( + "dynamic_challenge", + sa.Column("function", sa.String(length=32), nullable=True), + ) + if "initial" not in columns: + op.add_column( + "dynamic_challenge", sa.Column("initial", sa.Integer(), nullable=True) + ) + if "minimum" not in columns: + op.add_column( + "dynamic_challenge", sa.Column("minimum", sa.Integer(), nullable=True) + ) + + # Copy data from new columns back to old columns + connection = op.get_bind() + url = str(connection.engine.url) + if url.startswith("postgres"): + connection.execute( + sa.text( + """ + UPDATE dynamic_challenge + SET initial = dynamic_initial, + minimum = dynamic_minimum, + decay = dynamic_decay, + function = dynamic_function + """ + ) + ) + else: + connection.execute( + sa.text( + """ + UPDATE dynamic_challenge + SET initial = dynamic_initial, + minimum = dynamic_minimum, + decay = dynamic_decay, + `function` = dynamic_function + """ + ) + ) + + # Drop new columns + if "dynamic_function" in columns: + op.drop_column("dynamic_challenge", "dynamic_function") + if "dynamic_decay" in columns: + op.drop_column("dynamic_challenge", "dynamic_decay") + if "dynamic_minimum" in columns: + op.drop_column("dynamic_challenge", "dynamic_minimum") + if "dynamic_initial" in columns: + op.drop_column("dynamic_challenge", "dynamic_initial") diff --git a/CTFd/plugins/dynamic_challenges/migrations/b37fb68807ea_add_cascading_delete_to_dynamic_.py b/CTFd/plugins/dynamic_challenges/migrations/b37fb68807ea_add_cascading_delete_to_dynamic_.py new file mode 100644 index 0000000..d704fac --- /dev/null +++ b/CTFd/plugins/dynamic_challenges/migrations/b37fb68807ea_add_cascading_delete_to_dynamic_.py @@ -0,0 +1,58 @@ +"""Add cascading delete to dynamic challenges + +Revision ID: b37fb68807ea +Revises: +Create Date: 2020-05-06 12:21:39.373983 + +""" +import sqlalchemy + +revision = "b37fb68807ea" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(op=None): + bind = op.get_bind() + url = str(bind.engine.url) + + try: + if url.startswith("mysql"): + op.drop_constraint( + "dynamic_challenge_ibfk_1", "dynamic_challenge", type_="foreignkey" + ) + elif url.startswith("postgres"): + op.drop_constraint( + "dynamic_challenge_id_fkey", "dynamic_challenge", type_="foreignkey" + ) + except sqlalchemy.exc.InternalError as e: + print(str(e)) + + try: + op.create_foreign_key( + None, "dynamic_challenge", "challenges", ["id"], ["id"], ondelete="CASCADE" + ) + except sqlalchemy.exc.InternalError as e: + print(str(e)) + + +def downgrade(op=None): + bind = op.get_bind() + url = str(bind.engine.url) + try: + if url.startswith("mysql"): + op.drop_constraint( + "dynamic_challenge_ibfk_1", "dynamic_challenge", type_="foreignkey" + ) + elif url.startswith("postgres"): + op.drop_constraint( + "dynamic_challenge_id_fkey", "dynamic_challenge", type_="foreignkey" + ) + except sqlalchemy.exc.InternalError as e: + print(str(e)) + + try: + op.create_foreign_key(None, "dynamic_challenge", "challenges", ["id"], ["id"]) + except sqlalchemy.exc.InternalError as e: + print(str(e)) diff --git a/CTFd/plugins/dynamic_challenges/migrations/eb68f277ab61_add_func_column_to_dynamic_challenges.py b/CTFd/plugins/dynamic_challenges/migrations/eb68f277ab61_add_func_column_to_dynamic_challenges.py new file mode 100644 index 0000000..0998aa7 --- /dev/null +++ b/CTFd/plugins/dynamic_challenges/migrations/eb68f277ab61_add_func_column_to_dynamic_challenges.py @@ -0,0 +1,44 @@ +"""Add func column to dynamic_challenges + +Revision ID: eb68f277ab61 +Revises: b37fb68807ea +Create Date: 2023-06-28 17:37:48.244827 + +""" +import sqlalchemy as sa + +from CTFd.plugins.migrations import get_columns_for_table + +revision = "eb68f277ab61" +down_revision = "b37fb68807ea" +branch_labels = None +depends_on = None + + +def upgrade(op=None): + columns = get_columns_for_table( + op=op, table_name="dynamic_challenge", names_only=True + ) + if "function" not in columns: + op.add_column( + "dynamic_challenge", + sa.Column("function", sa.String(length=32), nullable=True), + ) + conn = op.get_bind() + url = str(conn.engine.url) + if url.startswith("postgres"): + conn.execute( + "UPDATE dynamic_challenge SET function = 'logarithmic' WHERE function IS NULL" + ) + else: + conn.execute( + "UPDATE dynamic_challenge SET `function` = 'logarithmic' WHERE `function` IS NULL" + ) + + +def downgrade(op=None): + columns = get_columns_for_table( + op=op, table_name="dynamic_challenge", names_only=True + ) + if "function" in columns: + op.drop_column("dynamic_challenge", "function") diff --git a/CTFd/plugins/flags/__init__.py b/CTFd/plugins/flags/__init__.py new file mode 100644 index 0000000..a468d11 --- /dev/null +++ b/CTFd/plugins/flags/__init__.py @@ -0,0 +1,83 @@ +import re + +from CTFd.plugins import register_plugin_assets_directory + + +class FlagException(Exception): + def __init__(self, message): + self.message = message + + def __str__(self): + return self.message + + +class BaseFlag(object): + name = None + templates = {} + + @staticmethod + def compare(self, saved, provided): + return True + + +class CTFdStaticFlag(BaseFlag): + name = "static" + templates = { # Nunjucks templates used for key editing & viewing + "create": "/plugins/flags/assets/static/create.html", + "update": "/plugins/flags/assets/static/edit.html", + } + + @staticmethod + def compare(chal_key_obj, provided): + saved = chal_key_obj.content + data = chal_key_obj.data + + if len(saved) != len(provided): + return False + result = 0 + + if data == "case_insensitive": + for x, y in zip(saved.lower(), provided.lower()): + result |= ord(x) ^ ord(y) + else: + for x, y in zip(saved, provided): + result |= ord(x) ^ ord(y) + return result == 0 + + +class CTFdRegexFlag(BaseFlag): + name = "regex" + templates = { # Nunjucks templates used for key editing & viewing + "create": "/plugins/flags/assets/regex/create.html", + "update": "/plugins/flags/assets/regex/edit.html", + } + + @staticmethod + def compare(chal_key_obj, provided): + saved = chal_key_obj.content + data = chal_key_obj.data + + try: + if data == "case_insensitive": + res = re.match(saved, provided, re.IGNORECASE) + else: + res = re.match(saved, provided) + # TODO: this needs plugin improvements. See #1425. + except re.error as e: + raise FlagException("Regex parse error occured") from e + + return res and res.group() == provided + + +FLAG_CLASSES = {"static": CTFdStaticFlag, "regex": CTFdRegexFlag} + + +def get_flag_class(class_id): + cls = FLAG_CLASSES.get(class_id) + if cls is None: + raise KeyError + return cls + + +def load(app): + register_plugin_assets_directory(app, base_path="/plugins/flags/assets/") diff --git a/CTFd/plugins/flags/assets/regex/create.html b/CTFd/plugins/flags/assets/regex/create.html new file mode 100644 index 0000000..cdad7b8 --- /dev/null +++ b/CTFd/plugins/flags/assets/regex/create.html @@ -0,0 +1,37 @@ + +
+ +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + +
DescriptionFlag
Match any content inside of flag{}:flag{.*}
Match numeric flags:flag{(\d+)}
Accept flags with or without the flag format prefix:(flag{)?this_is_a_flag(})?
+
+ \ No newline at end of file diff --git a/CTFd/plugins/flags/assets/regex/edit.html b/CTFd/plugins/flags/assets/regex/edit.html new file mode 100644 index 0000000..e07e5de --- /dev/null +++ b/CTFd/plugins/flags/assets/regex/edit.html @@ -0,0 +1,19 @@ + +
+ +
+
+ +
+ + +
+
+ +
diff --git a/CTFd/plugins/flags/assets/static/create.html b/CTFd/plugins/flags/assets/static/create.html new file mode 100644 index 0000000..4f9fa8b --- /dev/null +++ b/CTFd/plugins/flags/assets/static/create.html @@ -0,0 +1,14 @@ + +
+ +
+
+ +
+ \ No newline at end of file diff --git a/CTFd/plugins/flags/assets/static/edit.html b/CTFd/plugins/flags/assets/static/edit.html new file mode 100644 index 0000000..1ef3e11 --- /dev/null +++ b/CTFd/plugins/flags/assets/static/edit.html @@ -0,0 +1,19 @@ + +
+ +
+
+ +
+ + +
+
+ +
diff --git a/CTFd/plugins/flags/tests/__init__.py b/CTFd/plugins/flags/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/CTFd/plugins/flags/tests/test_flags.py b/CTFd/plugins/flags/tests/test_flags.py new file mode 100644 index 0000000..04d3a9b --- /dev/null +++ b/CTFd/plugins/flags/tests/test_flags.py @@ -0,0 +1,34 @@ +from CTFd.plugins.flags import CTFdRegexFlag + + +def test_valid_regex_match_case_sensitive(): + """ + Test a valid regex match in a case-sensitive manner using CTFdRegexFlag + """ + flag = CTFdRegexFlag() + flag.content = r"^[A-Z]\d{3}$" + flag.data = "case_sensitive" + provided_flag = "A123" + assert flag.compare(flag, provided_flag) # nosec + + +def test_valid_regex_match_case_insensitive(): + """ + Test a valid regex match in a case-insensitive manner using CTFdRegexFlag + """ + flag = CTFdRegexFlag() + flag.content = r"^[a-z]\d{3}$" + flag.data = "case_insensitive" + provided_flag = "A123" + assert flag.compare(flag, provided_flag) # nosec + + +def test_invalid_regex_match(): + """ + Test an invalid regex match using CTFdRegexFlag + """ + flag = CTFdRegexFlag() + flag.content = r"^[A-Z]\d{3}$" + flag.data = "case_sensitive" + provided_flag = "invalid" + assert not flag.compare(flag, provided_flag) # nosec diff --git a/CTFd/plugins/migrations.py b/CTFd/plugins/migrations.py new file mode 100644 index 0000000..36bcae4 --- /dev/null +++ b/CTFd/plugins/migrations.py @@ -0,0 +1,109 @@ +import inspect # noqa: I001 +import os + +from alembic.config import Config +from alembic.migration import MigrationContext +from alembic.operations import Operations +from alembic.script import ScriptDirectory +from flask import current_app +from sqlalchemy import create_engine +from sqlalchemy import inspect as SQLAInspect +from sqlalchemy import pool + +from CTFd.utils import _get_config, set_config + + +def get_all_tables(op): + """ + Function to list all the tables in the database from a migration + """ + inspector = SQLAInspect(op.get_bind()) + tables = inspector.get_table_names() + return tables + + +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 + + +def current(plugin_name=None): + if plugin_name is None: + # Get the directory name of the plugin if unspecified + # Doing it this way doesn't waste the rest of the inspect.stack call + frame = inspect.currentframe() + caller_info = inspect.getframeinfo(frame.f_back) + caller_path = caller_info[0] + plugin_name = os.path.basename(os.path.dirname(caller_path)) + + # Specifically bypass the cached config so that we always get the database value + version = _get_config.__wrapped__(plugin_name + "_alembic_version") + if version == KeyError: + version = None + return version + + +def upgrade(plugin_name=None, revision=None, lower="current"): + database_url = current_app.config.get("SQLALCHEMY_DATABASE_URI") + if database_url.startswith("sqlite"): + current_app.db.create_all() + return + + if plugin_name is None: + # Get the directory name of the plugin if unspecified + # Doing it this way doesn't waste the rest of the inspect.stack call + frame = inspect.currentframe() + caller_info = inspect.getframeinfo(frame.f_back) + caller_path = caller_info[0] + plugin_name = os.path.basename(os.path.dirname(caller_path)) + + # Check if the plugin has migraitons + migrations_path = os.path.join(current_app.plugins_dir, plugin_name, "migrations") + if os.path.isdir(migrations_path) is False: + return + + engine = create_engine(database_url, poolclass=pool.NullPool) + conn = engine.connect() + context = MigrationContext.configure(conn) + op = Operations(context) + + # Find the list of migrations to run + config = Config() + config.set_main_option("script_location", migrations_path) + config.set_main_option("version_locations", migrations_path) + script = ScriptDirectory.from_config(config) + + # Choose base revision for plugin upgrade + # "current" points to the current plugin version stored in config + # None represents the absolute base layer (e.g. first installation) + if lower == "current": + lower = current(plugin_name) + + # Do we upgrade to head or to a specific revision + if revision is None: + upper = script.get_current_head() + else: + upper = revision + + # Apply from lower to upper + revs = list(script.iterate_revisions(lower=lower, upper=upper)) + revs.reverse() + + try: + for r in revs: + with context.begin_transaction(): + r.module.upgrade(op=op) + # Set revision that succeeded so we don't need + # to start from the beginning on failure + set_config(plugin_name + "_alembic_version", r.revision) + finally: + conn.close() + + # Set the new latest revision + set_config(plugin_name + "_alembic_version", upper) diff --git a/CTFd/schemas/__init__.py b/CTFd/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/CTFd/schemas/awards.py b/CTFd/schemas/awards.py new file mode 100644 index 0000000..728e902 --- /dev/null +++ b/CTFd/schemas/awards.py @@ -0,0 +1,48 @@ +from CTFd.models import Awards, ma +from CTFd.utils import string_types + + +class AwardSchema(ma.ModelSchema): + class Meta: + model = Awards + include_fk = True + dump_only = ("id", "date") + + views = { + "admin": [ + "category", + "user_id", + "name", + "description", + "value", + "team_id", + "user", + "team", + "date", + "requirements", + "id", + "icon", + ], + "user": [ + "category", + "user_id", + "name", + "description", + "value", + "team_id", + "user", + "team", + "date", + "id", + "icon", + ], + } + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(AwardSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/brackets.py b/CTFd/schemas/brackets.py new file mode 100644 index 0000000..f8b6905 --- /dev/null +++ b/CTFd/schemas/brackets.py @@ -0,0 +1,8 @@ +from CTFd.models import Brackets, ma + + +class BracketSchema(ma.ModelSchema): + class Meta: + model = Brackets + include_fk = True + dump_only = ("id",) diff --git a/CTFd/schemas/challenges.py b/CTFd/schemas/challenges.py new file mode 100644 index 0000000..468ab32 --- /dev/null +++ b/CTFd/schemas/challenges.py @@ -0,0 +1,74 @@ +from marshmallow import validate +from marshmallow.exceptions import ValidationError +from marshmallow_sqlalchemy import field_for + +from CTFd.models import Challenges, ma + + +class ChallengeRequirementsValidator(validate.Validator): + default_message = "Error parsing challenge requirements" + + def __init__(self, error=None): + self.error = error or self.default_message + + def __call__(self, value): + if isinstance(value, dict) is False: + raise ValidationError(self.default_message) + + prereqs = value.get("prerequisites", []) + if all(prereqs) is False: + raise ValidationError( + "Challenge requirements cannot have a null prerequisite" + ) + + return value + + +class ChallengeSchema(ma.ModelSchema): + class Meta: + model = Challenges + include_fk = True + dump_only = ("id",) + + name = field_for( + Challenges, + "name", + validate=[ + validate.Length( + min=0, + max=80, + error="Challenge could not be saved. Challenge name too long", + ) + ], + ) + + category = field_for( + Challenges, + "category", + validate=[ + validate.Length( + min=0, + max=80, + error="Challenge could not be saved. Challenge category too long", + ) + ], + ) + + description = field_for( + Challenges, + "description", + allow_none=True, + validate=[ + validate.Length( + min=0, + max=65535, + error="Challenge could not be saved. Challenge description too long", + ) + ], + ) + + requirements = field_for( + Challenges, + "requirements", + validate=[ChallengeRequirementsValidator()], + ) diff --git a/CTFd/schemas/comments.py b/CTFd/schemas/comments.py new file mode 100644 index 0000000..d5ef43b --- /dev/null +++ b/CTFd/schemas/comments.py @@ -0,0 +1,14 @@ +from marshmallow import fields + +from CTFd.models import Comments, ma +from CTFd.schemas.users import UserSchema + + +class CommentSchema(ma.ModelSchema): + class Meta: + model = Comments + include_fk = True + dump_only = ("id", "date", "html", "author", "author_id", "type") + + author = fields.Nested(UserSchema(only=("name",))) + html = fields.String() diff --git a/CTFd/schemas/config.py b/CTFd/schemas/config.py new file mode 100644 index 0000000..266c18f --- /dev/null +++ b/CTFd/schemas/config.py @@ -0,0 +1,43 @@ +from marshmallow import fields +from marshmallow.exceptions import ValidationError +from marshmallow_sqlalchemy import field_for + +from CTFd.models import Configs, ma +from CTFd.utils import string_types + + +class ConfigValueField(fields.Field): + """ + Custom value field for Configs so that we can perform validation of values + """ + + def _deserialize(self, value, attr, data, **kwargs): + if isinstance(value, str): + # 65535 bytes is the size of a TEXT column in MySQL + # You may be able to exceed this in other databases + # but MySQL is our database of record + if len(value) > 65535: + raise ValidationError(f'{data["key"]} config is too long') + return value + else: + return value + + +class ConfigSchema(ma.ModelSchema): + class Meta: + model = Configs + include_fk = True + dump_only = ("id",) + + views = {"admin": ["id", "key", "value"]} + key = field_for(Configs, "key", required=True) + value = ConfigValueField(allow_none=True, required=True) + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(ConfigSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/fields.py b/CTFd/schemas/fields.py new file mode 100644 index 0000000..0199b9d --- /dev/null +++ b/CTFd/schemas/fields.py @@ -0,0 +1,38 @@ +from marshmallow import fields + +from CTFd.models import Fields, TeamFieldEntries, UserFieldEntries, db, ma + + +class FieldSchema(ma.ModelSchema): + class Meta: + model = Fields + include_fk = True + dump_only = ("id",) + + +class UserFieldEntriesSchema(ma.ModelSchema): + class Meta: + model = UserFieldEntries + sqla_session = db.session + include_fk = True + load_only = ("id",) + exclude = ("field", "user", "user_id") + dump_only = ("user_id", "name", "description", "type") + + name = fields.Nested(FieldSchema, only=("name"), attribute="field") + description = fields.Nested(FieldSchema, only=("description"), attribute="field") + type = fields.Nested(FieldSchema, only=("field_type"), attribute="field") + + +class TeamFieldEntriesSchema(ma.ModelSchema): + class Meta: + model = TeamFieldEntries + sqla_session = db.session + include_fk = True + load_only = ("id",) + exclude = ("field", "team", "team_id") + dump_only = ("team_id", "name", "description", "type") + + name = fields.Nested(FieldSchema, only=("name"), attribute="field") + description = fields.Nested(FieldSchema, only=("description"), attribute="field") + type = fields.Nested(FieldSchema, only=("field_type"), attribute="field") diff --git a/CTFd/schemas/files.py b/CTFd/schemas/files.py new file mode 100644 index 0000000..c401aa6 --- /dev/null +++ b/CTFd/schemas/files.py @@ -0,0 +1,18 @@ +from CTFd.models import Files, ma +from CTFd.utils import string_types + + +class FileSchema(ma.ModelSchema): + class Meta: + model = Files + include_fk = True + dump_only = ("id", "type", "location") + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(FileSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/flags.py b/CTFd/schemas/flags.py new file mode 100644 index 0000000..3af60bc --- /dev/null +++ b/CTFd/schemas/flags.py @@ -0,0 +1,18 @@ +from CTFd.models import Flags, ma +from CTFd.utils import string_types + + +class FlagSchema(ma.ModelSchema): + class Meta: + model = Flags + include_fk = True + dump_only = ("id",) + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(FlagSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/hints.py b/CTFd/schemas/hints.py new file mode 100644 index 0000000..32fbab0 --- /dev/null +++ b/CTFd/schemas/hints.py @@ -0,0 +1,43 @@ +from CTFd.models import Hints, ma +from CTFd.utils import string_types + + +class HintSchema(ma.ModelSchema): + class Meta: + model = Hints + include_fk = True + dump_only = ("id", "type", "html") + + views = { + "locked": ["id", "title", "type", "challenge", "challenge_id", "cost"], + "unlocked": [ + "id", + "title", + "type", + "challenge", + "challenge_id", + "content", + "html", + "cost", + ], + "admin": [ + "id", + "title", + "type", + "challenge", + "challenge_id", + "content", + "html", + "cost", + "requirements", + ], + } + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(HintSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/notifications.py b/CTFd/schemas/notifications.py new file mode 100644 index 0000000..8c3adf2 --- /dev/null +++ b/CTFd/schemas/notifications.py @@ -0,0 +1,23 @@ +from marshmallow import fields + +from CTFd.models import Notifications, ma +from CTFd.utils import string_types + + +class NotificationSchema(ma.ModelSchema): + class Meta: + model = Notifications + include_fk = True + dump_only = ("id", "date", "html") + + # Used to force the schema to include the html property from the model + html = fields.Str() + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(NotificationSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/pages.py b/CTFd/schemas/pages.py new file mode 100644 index 0000000..16a6c1e --- /dev/null +++ b/CTFd/schemas/pages.py @@ -0,0 +1,77 @@ +from marshmallow import pre_load, validate +from marshmallow_sqlalchemy import field_for + +from CTFd.models import Pages, ma +from CTFd.utils import string_types + + +class PageSchema(ma.ModelSchema): + class Meta: + model = Pages + include_fk = True + dump_only = ("id",) + + title = field_for( + Pages, + "title", + validate=[ + validate.Length( + min=0, + max=80, + error="Page could not be saved. Your title is too long.", + ) + ], + ) + + route = field_for( + Pages, + "route", + allow_none=True, + validate=[ + validate.Length( + min=0, + max=128, + error="Page could not be saved. Your route is too long.", + ) + ], + ) + + content = field_for( + Pages, + "content", + allow_none=True, + validate=[ + validate.Length( + min=0, + max=65535, + error="Page could not be saved. Your content is too long.", + ) + ], + ) + + link_target = field_for( + Pages, + "link_target", + allow_none=True, + validate=[ + validate.OneOf( + choices=[None, "_self", "_blank"], + error="Invalid link target", + ) + ], + ) + + @pre_load + def validate_route(self, data): + route = data.get("route") + if route and route.startswith("/"): + data["route"] = route.strip("/") + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(PageSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/ratings.py b/CTFd/schemas/ratings.py new file mode 100644 index 0000000..ddd677e --- /dev/null +++ b/CTFd/schemas/ratings.py @@ -0,0 +1,38 @@ +from marshmallow import fields + +from CTFd.models import Ratings, ma +from CTFd.schemas.challenges import ChallengeSchema +from CTFd.schemas.users import UserSchema +from CTFd.utils import string_types + + +class RatingSchema(ma.ModelSchema): + user = fields.Nested(UserSchema, only=["id", "name"]) + challenge = fields.Nested(ChallengeSchema, only=["id", "name", "category"]) + + class Meta: + model = Ratings + include_fk = True + dump_only = ("id", "date", "user_id", "challenge_id") + + views = { + "admin": [ + "id", + "user_id", + "user", + "challenge_id", + "challenge", + "value", + "review", + "date", + ], + } + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(RatingSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/solutions.py b/CTFd/schemas/solutions.py new file mode 100644 index 0000000..390cf54 --- /dev/null +++ b/CTFd/schemas/solutions.py @@ -0,0 +1,40 @@ +from CTFd.models import Solutions, ma +from CTFd.utils import string_types + + +class SolutionSchema(ma.ModelSchema): + class Meta: + model = Solutions + include_fk = True + dump_only = ("id",) + + views = { + "locked": [ + "id", + "challenge_id", + "state", + ], + "unlocked": [ + "id", + "challenge_id", + "content", + "html", + "state", + ], + "admin": [ + "id", + "challenge_id", + "content", + "html", + "state", + ], + } + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(SolutionSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/submissions.py b/CTFd/schemas/submissions.py new file mode 100644 index 0000000..62ef1ec --- /dev/null +++ b/CTFd/schemas/submissions.py @@ -0,0 +1,52 @@ +from marshmallow import fields + +from CTFd.models import Submissions, ma +from CTFd.schemas.challenges import ChallengeSchema +from CTFd.schemas.teams import TeamSchema +from CTFd.schemas.users import UserSchema +from CTFd.utils import string_types + + +class SubmissionSchema(ma.ModelSchema): + challenge = fields.Nested(ChallengeSchema, only=["id", "name", "category", "value"]) + user = fields.Nested(UserSchema, only=["id", "name"]) + team = fields.Nested(TeamSchema, only=["id", "name"]) + + class Meta: + model = Submissions + include_fk = True + dump_only = ("id",) + + views = { + "admin": [ + "provided", + "ip", + "challenge_id", + "challenge", + "user", + "team", + "date", + "type", + "id", + ], + "user": ["challenge_id", "challenge", "user", "team", "date", "type", "id"], + "self": [ + "challenge_id", + "challenge", + "user", + "team", + "date", + "type", + "id", + "provided", + ], + } + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(SubmissionSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/tags.py b/CTFd/schemas/tags.py new file mode 100644 index 0000000..ac4039e --- /dev/null +++ b/CTFd/schemas/tags.py @@ -0,0 +1,20 @@ +from CTFd.models import Tags, ma +from CTFd.utils import string_types + + +class TagSchema(ma.ModelSchema): + class Meta: + model = Tags + include_fk = True + dump_only = ("id",) + + views = {"admin": ["id", "challenge", "value"], "user": ["value"]} + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(TagSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/teams.py b/CTFd/schemas/teams.py new file mode 100644 index 0000000..eb3de62 --- /dev/null +++ b/CTFd/schemas/teams.py @@ -0,0 +1,416 @@ +from marshmallow import ValidationError, post_dump, pre_load, validate +from marshmallow.fields import Nested +from marshmallow_sqlalchemy import field_for +from sqlalchemy.orm import load_only + +from CTFd.models import Brackets, TeamFieldEntries, TeamFields, Teams, Users, ma +from CTFd.schemas.fields import TeamFieldEntriesSchema +from CTFd.utils import get_config, string_types +from CTFd.utils.crypto import verify_password +from CTFd.utils.user import get_current_team, get_current_user, is_admin +from CTFd.utils.validators import validate_country_code + + +class TeamSchema(ma.ModelSchema): + class Meta: + model = Teams + include_fk = True + dump_only = ("id", "oauth_id", "created", "members") + load_only = ("password",) + + name = field_for( + Teams, + "name", + required=True, + allow_none=False, + validate=[ + validate.Length(min=1, max=128, error="Team names must not be empty") + ], + ) + email = field_for( + Teams, + "email", + allow_none=False, + validate=validate.Email("Emails must be a properly formatted email address"), + ) + password = field_for(Teams, "password", required=True, allow_none=False) + website = field_for( + Teams, + "website", + validate=[ + # This is a dirty hack to let website accept empty strings so you can remove your website + lambda website: validate.URL( + error="Websites must be a proper URL starting with http or https", + schemes={"http", "https"}, + )(website) + if website + else True + ], + ) + country = field_for(Teams, "country", validate=[validate_country_code]) + bracket_id = field_for(Teams, "bracket_id") + fields = Nested( + TeamFieldEntriesSchema, partial=True, many=True, attribute="field_entries" + ) + + @pre_load + def validate_name(self, data): + name = data.get("name") + if name is None: + return + name = name.strip() + + existing_team = Teams.query.filter_by(name=name).first() + current_team = get_current_team() + # Admins should be able to patch anyone but they cannot cause a collision. + if is_admin(): + team_id = int(data.get("id", 0)) + if team_id: + if existing_team and existing_team.id != team_id: + raise ValidationError( + "Team name has already been taken", field_names=["name"] + ) + else: + # If there's no Team ID it means that the admin is creating a team with no ID. + if existing_team: + if current_team: + if current_team.id != existing_team.id: + raise ValidationError( + "Team name has already been taken", field_names=["name"] + ) + else: + raise ValidationError( + "Team name has already been taken", field_names=["name"] + ) + else: + # We need to allow teams to edit themselves and allow the "conflict" + if data["name"] == current_team.name: + return data + else: + name_changes = get_config("name_changes", default=True) + if bool(name_changes) is False: + raise ValidationError( + "Name changes are disabled", field_names=["name"] + ) + + if existing_team: + raise ValidationError( + "Team name has already been taken", field_names=["name"] + ) + + @pre_load + def validate_email(self, data): + email = data.get("email") + if email is None: + return + + existing_team = Teams.query.filter_by(email=email).first() + if is_admin(): + team_id = data.get("id") + if team_id: + if existing_team and existing_team.id != team_id: + raise ValidationError( + "Email address has already been used", field_names=["email"] + ) + else: + if existing_team: + raise ValidationError( + "Email address has already been used", field_names=["email"] + ) + else: + current_team = get_current_team() + if email == current_team.email: + return data + else: + if existing_team: + raise ValidationError( + "Email address has already been used", field_names=["email"] + ) + + @pre_load + def validate_password_confirmation(self, data): + password = data.get("password") + confirm = data.get("confirm") + + if is_admin(): + pass + else: + current_team = get_current_team() + current_user = get_current_user() + + if current_team.captain_id != current_user.id: + raise ValidationError( + "Only the captain can change the team password", + field_names=["captain_id"], + ) + + if current_team.password is None: + return + + if password and (bool(confirm) is False): + raise ValidationError( + "Please confirm your current password", field_names=["confirm"] + ) + + if password and confirm: + test_team = verify_password( + plaintext=confirm, ciphertext=current_team.password + ) + test_captain = verify_password( + plaintext=confirm, ciphertext=current_user.password + ) + if test_team is True or test_captain is True: + return data + else: + raise ValidationError( + "Your previous password is incorrect", field_names=["confirm"] + ) + else: + data.pop("password", None) + data.pop("confirm", None) + + @pre_load + def validate_captain_id(self, data): + captain_id = data.get("captain_id") + if captain_id is None: + return + + if is_admin(): + team_id = data.get("id") + if team_id: + target_team = Teams.query.filter_by(id=team_id).first() + else: + target_team = get_current_team() + captain = Users.query.filter_by(id=captain_id).first() + if captain in target_team.members: + return + else: + raise ValidationError("Invalid Captain ID", field_names=["captain_id"]) + else: + current_team = get_current_team() + current_user = get_current_user() + if current_team.captain_id == current_user.id: + captain = Users.query.filter_by(id=captain_id).first() + if captain in current_team.members: + return + else: + raise ValidationError( + "Only team members can be promoted to captain", + field_names=["captain_id"], + ) + else: + raise ValidationError( + "Only the captain can change team captain", + field_names=["captain_id"], + ) + + @pre_load + def validate_bracket_id(self, data): + bracket_id = data.get("bracket_id") + if is_admin(): + bracket = Brackets.query.filter_by(id=bracket_id).first() + if bracket is None: + ValidationError( + "Please provide a valid bracket id", field_names=["bracket_id"] + ) + else: + current_team = get_current_team() + # Teams are not allowed to switch their bracket + if bracket_id is None: + # Remove bracket_id and short circuit processing + data.pop("bracket_id", None) + return + if ( + current_team.bracket_id == int(bracket_id) + or current_team.bracket_id is None + ): + bracket = Brackets.query.filter_by(id=bracket_id, type="teams").first() + if bracket is None: + ValidationError( + "Please provide a valid bracket id", field_names=["bracket_id"] + ) + else: + raise ValidationError( + "Please contact an admin to change your bracket", + field_names=["bracket_id"], + ) + + @pre_load + def validate_fields(self, data): + """ + This validator is used to only allow users to update the field entry for their user. + It's not possible to exclude it because without the PK Marshmallow cannot load the right instance + """ + fields = data.get("fields") + if fields is None: + return + + current_team = get_current_team() + + if is_admin(): + team_id = data.get("id") + if team_id: + target_team = Teams.query.filter_by(id=data["id"]).first() + else: + target_team = current_team + + # We are editting an existing + if self.view == "admin" and self.instance: + target_team = self.instance + provided_ids = [] + for f in fields: + f.pop("id", None) + field_id = f.get("field_id") + + # # Check that we have an existing field for this. May be unnecessary b/c the foriegn key should enforce + field = TeamFields.query.filter_by(id=field_id).first_or_404() + + # Get the existing field entry if one exists + entry = TeamFieldEntries.query.filter_by( + field_id=field.id, team_id=target_team.id + ).first() + if entry: + f["id"] = entry.id + provided_ids.append(entry.id) + + # Extremely dirty hack to prevent deleting previously provided data. + # This needs a better soln. + entries = ( + TeamFieldEntries.query.options(load_only("id")) + .filter_by(team_id=target_team.id) + .all() + ) + for entry in entries: + if entry.id not in provided_ids: + fields.append({"id": entry.id}) + else: + provided_ids = [] + for f in fields: + # Remove any existing set + f.pop("id", None) + field_id = f.get("field_id") + value = f.get("value") + + # # Check that we have an existing field for this. May be unnecessary b/c the foriegn key should enforce + field = TeamFields.query.filter_by(id=field_id).first_or_404() + + # Get the existing field entry if one exists + entry = TeamFieldEntries.query.filter_by( + field_id=field.id, team_id=current_team.id + ).first() + + if field.required is True: + if isinstance(value, str): + if value.strip() == "": + raise ValidationError( + f"Field '{field.name}' is required", + field_names=["fields"], + ) + + if field.editable is False and entry is not None: + raise ValidationError( + f"Field '{field.name}' cannot be editted", + field_names=["fields"], + ) + + if entry: + f["id"] = entry.id + provided_ids.append(entry.id) + + # Extremely dirty hack to prevent deleting previously provided data. + # This needs a better soln. + entries = ( + TeamFieldEntries.query.options(load_only("id")) + .filter_by(team_id=current_team.id) + .all() + ) + for entry in entries: + if entry.id not in provided_ids: + fields.append({"id": entry.id}) + + @post_dump + def process_fields(self, data): + """ + Handle permissions levels for fields. + This is post_dump to manipulate JSON instead of the raw db object + + Admins can see all fields. + Users (self) can see their edittable and public fields + Public (user) can only see public fields + """ + # Gather all possible fields + removed_field_ids = [] + fields = TeamFields.query.all() + + # Select fields for removal based on current view and properties of the field + for field in fields: + if self.view == "user": + if field.public is False: + removed_field_ids.append(field.id) + elif self.view == "self": + if field.editable is False and field.public is False: + removed_field_ids.append(field.id) + + # Rebuild fuilds + fields = data.get("fields") + if fields: + data["fields"] = [ + field for field in fields if field["field_id"] not in removed_field_ids + ] + + views = { + "user": [ + "website", + "name", + "country", + "affiliation", + "bracket_id", + "members", + "id", + "oauth_id", + "captain_id", + "fields", + ], + "self": [ + "website", + "name", + "email", + "country", + "affiliation", + "bracket_id", + "members", + "id", + "oauth_id", + "password", + "captain_id", + "fields", + ], + "admin": [ + "website", + "name", + "created", + "country", + "banned", + "email", + "affiliation", + "secret", + "bracket_id", + "members", + "hidden", + "id", + "oauth_id", + "password", + "captain_id", + "fields", + ], + } + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + self.view = view + + super(TeamSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/tokens.py b/CTFd/schemas/tokens.py new file mode 100644 index 0000000..094a01d --- /dev/null +++ b/CTFd/schemas/tokens.py @@ -0,0 +1,31 @@ +from CTFd.models import Tokens, ma +from CTFd.utils import string_types + + +class TokenSchema(ma.ModelSchema): + class Meta: + model = Tokens + include_fk = True + dump_only = ("id", "expiration", "type") + + views = { + "admin": [ + "id", + "type", + "user_id", + "created", + "expiration", + "description", + "value", + ], + "user": ["id", "type", "created", "expiration", "description"], + } + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(TokenSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/topics.py b/CTFd/schemas/topics.py new file mode 100644 index 0000000..c71e8d9 --- /dev/null +++ b/CTFd/schemas/topics.py @@ -0,0 +1,38 @@ +from CTFd.models import ChallengeTopics, Topics, ma +from CTFd.utils import string_types + + +class TopicSchema(ma.ModelSchema): + class Meta: + model = Topics + include_fk = True + dump_only = ("id",) + + views = {"admin": ["id", "value"]} + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(TopicSchema, self).__init__(*args, **kwargs) + + +class ChallengeTopicSchema(ma.ModelSchema): + class Meta: + model = ChallengeTopics + include_fk = True + dump_only = ("id",) + + views = {"admin": ["id", "challenge_id", "topic_id"]} + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(ChallengeTopicSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/unlocks.py b/CTFd/schemas/unlocks.py new file mode 100644 index 0000000..964b453 --- /dev/null +++ b/CTFd/schemas/unlocks.py @@ -0,0 +1,23 @@ +from CTFd.models import Unlocks, ma +from CTFd.utils import string_types + + +class UnlockSchema(ma.ModelSchema): + class Meta: + model = Unlocks + include_fk = True + dump_only = ("id", "date") + + views = { + "admin": ["user_id", "target", "team_id", "date", "type", "id"], + "user": ["target", "date", "type", "id"], + } + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(UnlockSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/users.py b/CTFd/schemas/users.py new file mode 100644 index 0000000..0fba350 --- /dev/null +++ b/CTFd/schemas/users.py @@ -0,0 +1,416 @@ +from marshmallow import ValidationError, post_dump, pre_load, validate +from marshmallow.fields import Nested +from marshmallow_sqlalchemy import field_for +from sqlalchemy.orm import load_only + +from CTFd.models import Brackets, UserFieldEntries, UserFields, Users, ma +from CTFd.schemas.fields import UserFieldEntriesSchema +from CTFd.utils import get_config, string_types +from CTFd.utils.crypto import verify_password +from CTFd.utils.email import check_email_is_blacklisted, check_email_is_whitelisted +from CTFd.utils.user import get_current_user, is_admin +from CTFd.utils.validators import validate_country_code, validate_language + + +class UserSchema(ma.ModelSchema): + class Meta: + model = Users + include_fk = True + dump_only = ("id", "oauth_id", "created", "team_id") + load_only = ("password",) + + name = field_for( + Users, + "name", + required=True, + allow_none=False, + validate=[ + validate.Length(min=1, max=128, error="User names must not be empty") + ], + ) + email = field_for( + Users, + "email", + allow_none=False, + validate=[ + validate.Email("Emails must be a properly formatted email address"), + validate.Length(min=1, max=128, error="Emails must not be empty"), + ], + ) + website = field_for( + Users, + "website", + validate=[ + # This is a dirty hack to let website accept empty strings so you can remove your website + lambda website: validate.URL( + error="Websites must be a proper URL starting with http or https", + schemes={"http", "https"}, + )(website) + if website + else True + ], + ) + language = field_for(Users, "language", validate=[validate_language]) + country = field_for(Users, "country", validate=[validate_country_code]) + password = field_for(Users, "password", required=True, allow_none=False) + bracket_id = field_for(Users, "bracket_id") + fields = Nested( + UserFieldEntriesSchema, partial=True, many=True, attribute="field_entries" + ) + + @pre_load + def validate_name(self, data): + name = data.get("name") + if name is None: + return + name = name.strip() + + existing_user = Users.query.filter_by(name=name).first() + current_user = get_current_user() + if is_admin(): + user_id = data.get("id") + if user_id: + if existing_user and existing_user.id != user_id: + raise ValidationError( + "User name has already been taken", field_names=["name"] + ) + else: + if existing_user: + if current_user: + if current_user.id != existing_user.id: + raise ValidationError( + "User name has already been taken", field_names=["name"] + ) + else: + raise ValidationError( + "User name has already been taken", field_names=["name"] + ) + else: + if name == current_user.name: + return data + else: + name_changes = get_config("name_changes", default=True) + if bool(name_changes) is False: + raise ValidationError( + "Name changes are disabled", field_names=["name"] + ) + if existing_user: + raise ValidationError( + "User name has already been taken", field_names=["name"] + ) + + @pre_load + def validate_email(self, data): + email = data.get("email") + if email is None: + return + email = email.strip() + + existing_user = Users.query.filter_by(email=email).first() + current_user = get_current_user() + if is_admin(): + user_id = data.get("id") + if user_id: + if existing_user and existing_user.id != user_id: + raise ValidationError( + "Email address has already been used", field_names=["email"] + ) + else: + if existing_user: + if current_user: + if current_user.id != existing_user.id: + raise ValidationError( + "Email address has already been used", + field_names=["email"], + ) + else: + raise ValidationError( + "Email address has already been used", field_names=["email"] + ) + else: + if email == current_user.email: + return data + else: + confirm = data.get("confirm") + + if bool(confirm) is False: + raise ValidationError( + "Please confirm your current password", field_names=["confirm"] + ) + + test = verify_password( + plaintext=confirm, ciphertext=current_user.password + ) + if test is False: + raise ValidationError( + "Your previous password is incorrect", field_names=["confirm"] + ) + + if existing_user: + raise ValidationError( + "Email address has already been used", field_names=["email"] + ) + if check_email_is_whitelisted(email) is False: + raise ValidationError( + "Email address is not from an allowed domain", + field_names=["email"], + ) + if check_email_is_blacklisted(email) is True: + raise ValidationError( + "Email address is not from an allowed domain", + field_names=["email"], + ) + if get_config("verify_emails"): + current_user.verified = False + + @pre_load + def validate_password_confirmation(self, data): + password = data.get("password") + confirm = data.get("confirm") + target_user = get_current_user() + + if is_admin(): + pass + else: + # If the user has no password set, allow them to set their password + if target_user.password is None: + return + + if password and (bool(confirm) is False): + raise ValidationError( + "Please confirm your current password", field_names=["confirm"] + ) + + if password and confirm: + password_min_length = int(get_config("password_min_length", default=0)) + if len(password) < password_min_length: + raise ValidationError( + f"Password must be at least {password_min_length} characters", + field_names=["password"], + ) + + test = verify_password( + plaintext=confirm, ciphertext=target_user.password + ) + if test is True: + return data + else: + raise ValidationError( + "Your previous password is incorrect", field_names=["confirm"] + ) + else: + data.pop("password", None) + data.pop("confirm", None) + + @pre_load + def validate_bracket_id(self, data): + bracket_id = data.get("bracket_id") + if is_admin(): + bracket = Brackets.query.filter_by(id=bracket_id, type="users").first() + if bracket is None: + ValidationError( + "Please provide a valid bracket id", field_names=["bracket_id"] + ) + else: + current_user = get_current_user() + # Users are not allowed to switch their bracket + if bracket_id is None: + # Remove bracket_id and short circuit processing + data.pop("bracket_id", None) + return + if ( + current_user.bracket_id == int(bracket_id) + or current_user.bracket_id is None + ): + bracket = Brackets.query.filter_by(id=bracket_id, type="users").first() + if bracket is None: + ValidationError( + "Please provide a valid bracket id", field_names=["bracket_id"] + ) + else: + raise ValidationError( + "Please contact an admin to change your bracket", + field_names=["bracket_id"], + ) + + @pre_load + def validate_fields(self, data): + """ + This validator is used to only allow users to update the field entry for their user. + It's not possible to exclude it because without the PK Marshmallow cannot load the right instance + """ + fields = data.get("fields") + if fields is None: + return + + current_user = get_current_user() + + if is_admin(): + user_id = data.get("id") + if user_id: + target_user = Users.query.filter_by(id=data["id"]).first() + else: + target_user = current_user + + # We are editting an existing user + if self.view == "admin" and self.instance: + target_user = self.instance + provided_ids = [] + for f in fields: + f.pop("id", None) + field_id = f.get("field_id") + + # # Check that we have an existing field for this. May be unnecessary b/c the foriegn key should enforce + field = UserFields.query.filter_by(id=field_id).first_or_404() + + # Get the existing field entry if one exists + entry = UserFieldEntries.query.filter_by( + field_id=field.id, user_id=target_user.id + ).first() + if entry: + f["id"] = entry.id + provided_ids.append(entry.id) + + # Extremely dirty hack to prevent deleting previously provided data. + # This needs a better soln. + entries = ( + UserFieldEntries.query.options(load_only("id")) + .filter_by(user_id=target_user.id) + .all() + ) + for entry in entries: + if entry.id not in provided_ids: + fields.append({"id": entry.id}) + else: + provided_ids = [] + for f in fields: + # Remove any existing set + f.pop("id", None) + field_id = f.get("field_id") + value = f.get("value") + + # # Check that we have an existing field for this. May be unnecessary b/c the foriegn key should enforce + field = UserFields.query.filter_by(id=field_id).first_or_404() + + # Get the existing field entry if one exists + entry = UserFieldEntries.query.filter_by( + field_id=field.id, user_id=current_user.id + ).first() + + if field.required is True: + if isinstance(value, str): + if value.strip() == "": + raise ValidationError( + f"Field '{field.name}' is required", + field_names=["fields"], + ) + + if field.editable is False and entry is not None: + raise ValidationError( + f"Field '{field.name}' cannot be editted", + field_names=["fields"], + ) + + if entry: + f["id"] = entry.id + provided_ids.append(entry.id) + + # Extremely dirty hack to prevent deleting previously provided data. + # This needs a better soln. + entries = ( + UserFieldEntries.query.options(load_only("id")) + .filter_by(user_id=current_user.id) + .all() + ) + for entry in entries: + if entry.id not in provided_ids: + fields.append({"id": entry.id}) + + @post_dump + def process_fields(self, data): + """ + Handle permissions levels for fields. + This is post_dump to manipulate JSON instead of the raw db object + + Admins can see all fields. + Users (self) can see their edittable and public fields + Public (user) can only see public fields + """ + # Gather all possible fields + removed_field_ids = [] + fields = UserFields.query.all() + + # Select fields for removal based on current view and properties of the field + for field in fields: + if self.view == "user": + if field.public is False: + removed_field_ids.append(field.id) + elif self.view == "self": + if field.editable is False and field.public is False: + removed_field_ids.append(field.id) + + # Rebuild fuilds + fields = data.get("fields") + if fields: + data["fields"] = [ + field for field in fields if field["field_id"] not in removed_field_ids + ] + + views = { + "user": [ + "website", + "name", + "country", + "affiliation", + "bracket_id", + "id", + "oauth_id", + "fields", + "team_id", + ], + "self": [ + "website", + "name", + "email", + "language", + "country", + "affiliation", + "bracket_id", + "id", + "oauth_id", + "password", + "fields", + "team_id", + ], + "admin": [ + "website", + "name", + "created", + "country", + "banned", + "email", + "language", + "affiliation", + "secret", + "bracket_id", + "hidden", + "id", + "oauth_id", + "password", + "type", + "verified", + "change_password", + "fields", + "team_id", + ], + } + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + self.view = view + + super(UserSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/scoreboard.py b/CTFd/scoreboard.py new file mode 100644 index 0000000..261813b --- /dev/null +++ b/CTFd/scoreboard.py @@ -0,0 +1,29 @@ +from flask import Blueprint, render_template + +from CTFd.utils import config +from CTFd.utils.config.visibility import scores_visible +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 + +scoreboard = Blueprint("scoreboard", __name__) + + +@scoreboard.route("/scoreboard") +@check_account_visibility +@check_score_visibility +def listing(): + infos = get_infos() + + if config.is_scoreboard_frozen(): + infos.append("Scoreboard has been frozen") + + if is_admin() is True and scores_visible() is False: + infos.append("Scores are not currently visible to users") + + standings = get_standings() + return render_template("scoreboard.html", standings=standings, infos=infos) diff --git a/CTFd/share.py b/CTFd/share.py new file mode 100644 index 0000000..d991a94 --- /dev/null +++ b/CTFd/share.py @@ -0,0 +1,36 @@ +from flask import Blueprint, abort, request + +from CTFd.utils import get_config +from CTFd.utils.social import get_social_share + +social = Blueprint("social", __name__) + + +@social.route("/share//assets/") +def assets(type, path): + if bool(get_config("social_shares", default=True)) is False: + abort(403) + SocialShare = get_social_share(type) + if SocialShare is None: + abort(404) + + s = SocialShare() + if path != s.mac + ".png": + abort(404) + + return s.asset(path) + + +@social.route("/share/") +def share(type): + if bool(get_config("social_shares", default=True)) is False: + abort(403) + SocialShare = get_social_share(type) + if SocialShare is None: + abort(404) + + s = SocialShare() + if request.args.get("mac") != s.mac: + abort(404) + + return s.content diff --git a/CTFd/teams.py b/CTFd/teams.py new file mode 100644 index 0000000..192d576 --- /dev/null +++ b/CTFd/teams.py @@ -0,0 +1,403 @@ +from flask import Blueprint, abort, redirect, render_template, request, url_for + +from CTFd.cache import clear_team_session, clear_user_session +from CTFd.exceptions import TeamTokenExpiredException, TeamTokenInvalidException +from CTFd.models import Brackets, TeamFieldEntries, TeamFields, Teams, db +from CTFd.utils import config, get_config, validators +from CTFd.utils.crypto import verify_password +from CTFd.utils.decorators import authed_only, ratelimit, registered_only +from CTFd.utils.decorators.modes import require_team_mode +from CTFd.utils.decorators.visibility import ( + check_account_visibility, + check_score_visibility, +) +from CTFd.utils.helpers import get_errors, get_infos +from CTFd.utils.humanize.words import pluralize +from CTFd.utils.user import get_current_user, get_current_user_attrs +from CTFd.utils.validators import ValidationError + +teams = Blueprint("teams", __name__) + + +@teams.route("/teams") +@check_account_visibility +@require_team_mode +def listing(): + q = request.args.get("q") + field = request.args.get("field", "name") + filters = [] + + if field not in ("name", "affiliation", "website"): + field = "name" + + if q: + filters.append(getattr(Teams, field).like("%{}%".format(q))) + + teams = ( + Teams.query.filter_by(hidden=False, banned=False) + .filter(*filters) + .order_by(Teams.id.asc()) + .paginate(per_page=50, error_out=False) + ) + + args = dict(request.args) + args.pop("page", 1) + + return render_template( + "teams/teams.html", + teams=teams, + prev_page=url_for(request.endpoint, page=teams.prev_num, **args), + next_page=url_for(request.endpoint, page=teams.next_num, **args), + q=q, + field=field, + ) + + +@teams.route("/teams/invite", methods=["GET", "POST"]) +@registered_only +@require_team_mode +def invite(): + infos = get_infos() + errors = get_errors() + code = request.args.get("code") + + if code is None: + abort(404) + + user = get_current_user_attrs() + if user.team_id: + errors.append("You are already in a team. You cannot join another.") + + try: + team = Teams.load_invite_code(code) + except TeamTokenExpiredException: + abort(403, description="This invite URL has expired") + except TeamTokenInvalidException: + abort(403, description="This invite URL is invalid") + + team_size_limit = get_config("team_size", default=0) + + if request.method == "GET": + if team_size_limit: + infos.append( + "Teams are limited to {limit} member{plural}".format( + limit=team_size_limit, plural=pluralize(number=team_size_limit) + ) + ) + + return render_template( + "teams/invite.html", team=team, infos=infos, errors=errors + ) + + if request.method == "POST": + if errors: + return ( + render_template( + "teams/invite.html", team=team, infos=infos, errors=errors + ), + 403, + ) + + if team_size_limit and len(team.members) >= team_size_limit: + errors.append( + "{name} has already reached the team size limit of {limit}".format( + name=team.name, limit=team_size_limit + ) + ) + return ( + render_template( + "teams/invite.html", team=team, infos=infos, errors=errors + ), + 403, + ) + + user = get_current_user() + user.team_id = team.id + db.session.commit() + + clear_user_session(user_id=user.id) + clear_team_session(team_id=team.id) + + return redirect(url_for("challenges.listing")) + + +@teams.route("/teams/join", methods=["GET", "POST"]) +@authed_only +@require_team_mode +@ratelimit(method="POST", limit=10, interval=5) +def join(): + infos = get_infos() + errors = get_errors() + + user = get_current_user_attrs() + if user.team_id: + errors.append("You are already in a team. You cannot join another.") + + if request.method == "GET": + team_size_limit = get_config("team_size", default=0) + if team_size_limit: + plural = "" if team_size_limit == 1 else "s" + infos.append( + "Teams are limited to {limit} member{plural}".format( + limit=team_size_limit, plural=plural + ) + ) + return render_template("teams/join_team.html", infos=infos, errors=errors) + + if request.method == "POST": + teamname = request.form.get("name") + passphrase = request.form.get("password", "").strip() + + team = Teams.query.filter_by(name=teamname).first() + + if errors: + return ( + render_template("teams/join_team.html", infos=infos, errors=errors), + 403, + ) + + if team and verify_password(passphrase, team.password): + team_size_limit = get_config("team_size", default=0) + if team_size_limit and len(team.members) >= team_size_limit: + errors.append( + "{name} has already reached the team size limit of {limit}".format( + name=team.name, limit=team_size_limit + ) + ) + return render_template( + "teams/join_team.html", infos=infos, errors=errors + ) + + user = get_current_user() + user.team_id = team.id + db.session.commit() + + if len(team.members) == 1: + team.captain_id = user.id + db.session.commit() + + clear_user_session(user_id=user.id) + clear_team_session(team_id=team.id) + + return redirect(url_for("challenges.listing")) + else: + errors.append("That information is incorrect") + return render_template("teams/join_team.html", infos=infos, errors=errors) + + +@teams.route("/teams/new", methods=["GET", "POST"]) +@authed_only +@require_team_mode +def new(): + infos = get_infos() + errors = get_errors() + + if bool(get_config("team_creation", default=True)) is False: + abort( + 403, + description="Team creation is currently disabled. Please join an existing team.", + ) + + num_teams_limit = int(get_config("num_teams", default=0)) + num_teams = Teams.query.filter_by(banned=False, hidden=False).count() + if num_teams_limit and num_teams >= num_teams_limit: + abort( + 403, + description=f"Reached the maximum number of teams ({num_teams_limit}). Please join an existing team.", + ) + + user = get_current_user_attrs() + if user.team_id: + errors.append("You are already in a team. You cannot join another.") + + if request.method == "GET": + team_size_limit = get_config("team_size", default=0) + if team_size_limit: + plural = "" if team_size_limit == 1 else "s" + infos.append( + "Teams are limited to {limit} member{plural}".format( + limit=team_size_limit, plural=plural + ) + ) + return render_template("teams/new_team.html", infos=infos, errors=errors) + + elif request.method == "POST": + teamname = request.form.get("name", "").strip() + passphrase = request.form.get("password", "").strip() + + website = request.form.get("website") + affiliation = request.form.get("affiliation") + country = request.form.get("country") + bracket_id = request.form.get("bracket_id", None) + + user = get_current_user() + + existing_team = Teams.query.filter_by(name=teamname).first() + if existing_team: + errors.append("That team name is already taken") + if not teamname: + errors.append("That team name is invalid") + + # Process additional user fields + fields = {} + for field in TeamFields.query.all(): + fields[field.id] = field + + entries = {} + for field_id, field in fields.items(): + value = request.form.get(f"fields[{field_id}]", "").strip() + if field.required is True and (value is None or value == ""): + errors.append("Please provide all required fields") + break + + if field.field_type == "boolean": + entries[field_id] = bool(value) + else: + entries[field_id] = value + + if website: + valid_website = validators.validate_url(website) + else: + valid_website = True + + if affiliation: + valid_affiliation = len(affiliation) < 128 + else: + valid_affiliation = True + + if bracket_id: + valid_bracket = bool( + Brackets.query.filter_by(id=bracket_id, type="teams").first() + ) + else: + if Brackets.query.filter_by(type="teams").count(): + valid_bracket = False + else: + valid_bracket = True + + if country: + try: + validators.validate_country_code(country) + valid_country = True + except ValidationError: + valid_country = False + else: + valid_country = True + + if valid_website is False: + errors.append("Websites must be a proper URL starting with http or https") + if valid_affiliation is False: + errors.append("Please provide a shorter affiliation") + if valid_country is False: + errors.append("Invalid country") + if valid_bracket is False: + errors.append("Please provide a valid bracket") + + if errors: + return render_template("teams/new_team.html", errors=errors), 403 + + # Hide the created team if the creator is an admin + hidden = False + if user.type == "admin": + hidden = True + + team = Teams( + name=teamname, + password=passphrase, + captain_id=user.id, + hidden=hidden, + bracket_id=bracket_id, + ) + + if website: + team.website = website + if affiliation: + team.affiliation = affiliation + if country: + team.country = country + + db.session.add(team) + db.session.commit() + + for field_id, value in entries.items(): + entry = TeamFieldEntries(field_id=field_id, value=value, team_id=team.id) + db.session.add(entry) + db.session.commit() + + user.team_id = team.id + db.session.commit() + + clear_user_session(user_id=user.id) + clear_team_session(team_id=team.id) + + return redirect(url_for("challenges.listing")) + + +@teams.route("/team") +@authed_only +@require_team_mode +def private(): + infos = get_infos() + errors = get_errors() + + user = get_current_user() + if not user.team_id: + return render_template("teams/team_enrollment.html") + + team_id = user.team_id + + team = Teams.query.filter_by(id=team_id).first_or_404() + solves = team.get_solves() + awards = team.get_awards() + + place = team.place + score = team.get_score(admin=True) + + if config.is_scoreboard_frozen(): + infos.append("Scoreboard has been frozen") + + return render_template( + "teams/private.html", + solves=solves, + awards=awards, + user=user, + team=team, + score=score, + place=place, + score_frozen=config.is_scoreboard_frozen(), + infos=infos, + errors=errors, + ) + + +@teams.route("/teams/") +@check_account_visibility +@check_score_visibility +@require_team_mode +def public(team_id): + infos = get_infos() + errors = get_errors() + team = Teams.query.filter_by(id=team_id, banned=False, hidden=False).first_or_404() + solves = team.get_solves() + awards = team.get_awards() + + place = team.place + score = team.score + + if errors: + return render_template("teams/public.html", team=team, errors=errors) + + if config.is_scoreboard_frozen(): + infos.append("Scoreboard has been frozen") + + return render_template( + "teams/public.html", + solves=solves, + awards=awards, + team=team, + score=score, + place=place, + score_frozen=config.is_scoreboard_frozen(), + infos=infos, + errors=errors, + ) diff --git a/CTFd/themes/admin/assets/css/admin.scss b/CTFd/themes/admin/assets/css/admin.scss new file mode 100644 index 0000000..a764234 --- /dev/null +++ b/CTFd/themes/admin/assets/css/admin.scss @@ -0,0 +1,128 @@ +@import "includes/sticky-footer.css"; + +.section-title { + border-top: 1px solid lightgray; + margin: 15px 0 5px 0; + font-size: 14px; + font-weight: bold; + padding: 10px 0 0 20px; + color: #636c76; +} + +// Intended for the Bootstrap navbar icons +.action-icon { + margin-right: 8px; + display: inline-block; + width: 1.25em; + text-align: center; +} + +// Custom color picker on Theme page +input[type="color"].custom-color-picker { + padding: 5px; + margin-right: 8px; + border: none; + height: 50px; + width: 50px; + vertical-align: middle; +} + +#score-graph { + min-height: 400px; + display: block; + clear: both; +} + +#solves-graph { + display: block; + height: 350px; +} + +#keys-pie-graph { + min-height: 400px; + display: block; +} + +#categories-pie-graph { + min-height: 400px; + display: block; +} + +#points-pie-graph { + min-height: 400px; + display: block; +} + +#solve-percentages-graph { + min-height: 400px; + display: block; +} + +#score-distribution-graph { + min-height: 400px; + display: block; +} + +.no-decoration { + color: inherit !important; + text-decoration: none !important; +} + +.no-decoration:hover { + color: inherit !important; + text-decoration: none !important; +} + +.table td, +.table th { + vertical-align: inherit; +} + +pre { + white-space: pre-wrap; + margin: 0; + padding: 0; +} + +.form-control { + position: relative; + display: block; + /*padding: 0.8em;*/ + border-radius: 0; + /*background: #f0f0f0;*/ + /*color: #aaa;*/ + font-weight: 400; + font-family: "Avenir Next", "Helvetica Neue", Helvetica, Arial, sans-serif; + -webkit-appearance: none; +} + +tbody tr:hover { + background-color: rgba(0, 0, 0, 0.1) !important; +} + +[data-href] { + cursor: pointer; +} + +.sort-col { + cursor: pointer; +} + +input[type="checkbox"] { + cursor: pointer; +} + +.card-radio:checked + .card { + background-color: transparent !important; + border-color: #a3d39c; + box-shadow: 0 0 0 0.1rem #a3d39c; + transition: + background-color 0.3s, + border-color 0.3s; +} +.card-radio:checked + .card .card-radio-clone { + visibility: visible !important; +} +.card:hover { + cursor: pointer; +} diff --git a/CTFd/themes/admin/assets/css/challenge-board.scss b/CTFd/themes/admin/assets/css/challenge-board.scss new file mode 100644 index 0000000..ef6bbbd --- /dev/null +++ b/CTFd/themes/admin/assets/css/challenge-board.scss @@ -0,0 +1,68 @@ +.chal-desc { + padding-left: 30px; + padding-right: 30px; + font-size: 14px; +} + +.chal-desc img { + max-width: 100%; + height: auto; +} + +.modal-content { + border-radius: 0px; + max-width: 1000px; + padding: 1em; + margin: 0 auto; +} + +.btn-info { + background-color: #5b7290 !important; +} + +.badge-info { + background-color: #5b7290 !important; +} + +.challenge-button { + box-shadow: 3px 3px 3px grey; +} + +.solved-challenge { + background-color: #37d63e !important; + opacity: 0.4; + border: none; +} + +.corner-button-check { + margin-top: -10px; + margin-right: 25px; + position: absolute; + right: 0; +} + +.key-submit .btn { + height: 51px; +} + +#challenge-window .form-control { + position: relative; + display: block; + padding: 0.8em; + border-radius: 0; + background: #f0f0f0; + color: #aaa; + font-weight: 400; + font-family: "Avenir Next", "Helvetica Neue", Helvetica, Arial, sans-serif; + -webkit-appearance: none; + height: auto !important; +} + +#challenge-window .form-control:focus { + background-color: transparent; + border-color: #a3d39c; + box-shadow: 0 0 0 0.1rem #a3d39c; + transition: + background-color 0.3s, + border-color 0.3s; +} diff --git a/CTFd/themes/admin/assets/css/codemirror.scss b/CTFd/themes/admin/assets/css/codemirror.scss new file mode 100644 index 0000000..09b6af4 --- /dev/null +++ b/CTFd/themes/admin/assets/css/codemirror.scss @@ -0,0 +1,6 @@ +@import "~/codemirror/lib/codemirror.css"; +@import "includes/easymde.scss"; +.CodeMirror.cm-s-default { + font-size: 12px; + border: 1px solid lightgray; +} diff --git a/CTFd/themes/admin/assets/css/fonts.scss b/CTFd/themes/admin/assets/css/fonts.scss new file mode 100644 index 0000000..145309b --- /dev/null +++ b/CTFd/themes/admin/assets/css/fonts.scss @@ -0,0 +1,28 @@ +@use "sass:map"; +@use "~/@fontsource/lato/scss/mixins.scss" as Lato; +@use "~/@fontsource/raleway/scss/mixins.scss" as Raleway; + +@include Lato.faces( + $subsets: all, + $weights: ( + 400, + 700, + ), + $styles: all, + $directory: "../webfonts" +); + +@include Raleway.faces( + $subsets: all, + $weights: ( + 500, + ), + $styles: all, + $directory: "../webfonts" +); + +$fa-font-display: auto !default; +@import "~/@fortawesome/fontawesome-free/scss/fontawesome.scss"; +@import "~/@fortawesome/fontawesome-free/scss/regular.scss"; +@import "~/@fortawesome/fontawesome-free/scss/solid.scss"; +@import "~/@fortawesome/fontawesome-free/scss/brands.scss"; diff --git a/CTFd/themes/admin/assets/css/includes/award-icons.scss b/CTFd/themes/admin/assets/css/includes/award-icons.scss new file mode 100644 index 0000000..149f39a --- /dev/null +++ b/CTFd/themes/admin/assets/css/includes/award-icons.scss @@ -0,0 +1,39 @@ +.award-icon { + font-family: "Font Awesome 5 Free", "Font Awesome 5 Free Offline"; + font-weight: 900; + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + display: inline-block; + font-style: normal; + font-variant: normal; + text-rendering: auto; + line-height: 1; +} + +.award-shield:before { + content: "\f3ed"; +} +.award-bug:before { + content: "\f188"; +} +.award-crown:before { + content: "\f521"; +} +.award-crosshairs:before { + content: "\f05b"; +} +.award-ban:before { + content: "\f05e"; +} +.award-lightning:before { + content: "\f0e7"; +} +.award-code:before { + content: "\f121"; +} +.award-cowboy:before { + content: "\f8c0"; +} +.award-angry:before { + content: "\f556"; +} diff --git a/CTFd/themes/admin/assets/css/includes/easymde.scss b/CTFd/themes/admin/assets/css/includes/easymde.scss new file mode 100644 index 0000000..fa6f858 --- /dev/null +++ b/CTFd/themes/admin/assets/css/includes/easymde.scss @@ -0,0 +1,381 @@ +.CodeMirror.cm-s-easymde { + box-sizing: border-box; + height: auto; + border: 1px solid lightgray; + padding: 10px; + font: inherit; + z-index: 0; + word-wrap: break-word; +} + +.CodeMirror-scroll { + overflow-y: hidden; + overflow-x: auto; +} + +.editor-toolbar { + position: relative; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + -o-user-select: none; + user-select: none; + padding: 0 10px; + border-top: 1px solid #bbb; + border-left: 1px solid #bbb; + border-right: 1px solid #bbb; +} + +.editor-toolbar:after, +.editor-toolbar:before { + display: block; + content: " "; + height: 1px; +} + +.editor-toolbar:before { + margin-bottom: 8px; +} + +.editor-toolbar:after { + margin-top: 8px; +} + +.editor-toolbar.fullscreen { + width: 100%; + height: 50px; + padding-top: 10px; + padding-bottom: 10px; + box-sizing: border-box; + background: #fff; + border: 0; + position: fixed; + top: 0; + left: 0; + opacity: 1; + z-index: 9; +} + +.editor-toolbar.fullscreen::before { + width: 20px; + height: 50px; + background: -moz-linear-gradient( + left, + rgba(255, 255, 255, 1) 0%, + rgba(255, 255, 255, 0) 100% + ); + background: -webkit-gradient( + linear, + left top, + right top, + color-stop(0%, rgba(255, 255, 255, 1)), + color-stop(100%, rgba(255, 255, 255, 0)) + ); + background: -webkit-linear-gradient( + left, + rgba(255, 255, 255, 1) 0%, + rgba(255, 255, 255, 0) 100% + ); + background: -o-linear-gradient( + left, + rgba(255, 255, 255, 1) 0%, + rgba(255, 255, 255, 0) 100% + ); + background: -ms-linear-gradient( + left, + rgba(255, 255, 255, 1) 0%, + rgba(255, 255, 255, 0) 100% + ); + background: linear-gradient( + to right, + rgba(255, 255, 255, 1) 0%, + rgba(255, 255, 255, 0) 100% + ); + position: fixed; + top: 0; + left: 0; + margin: 0; + padding: 0; +} + +.editor-toolbar.fullscreen::after { + width: 20px; + height: 50px; + background: -moz-linear-gradient( + left, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 1) 100% + ); + background: -webkit-gradient( + linear, + left top, + right top, + color-stop(0%, rgba(255, 255, 255, 0)), + color-stop(100%, rgba(255, 255, 255, 1)) + ); + background: -webkit-linear-gradient( + left, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 1) 100% + ); + background: -o-linear-gradient( + left, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 1) 100% + ); + background: -ms-linear-gradient( + left, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 1) 100% + ); + background: linear-gradient( + to right, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 1) 100% + ); + position: fixed; + top: 0; + right: 0; + margin: 0; + padding: 0; +} + +.editor-toolbar button, +.editor-toolbar .easymde-dropdown { + background: transparent; + display: inline-block; + text-align: center; + text-decoration: none !important; + height: 30px; + margin: 0; + padding: 0; + border: 1px solid transparent; + border-radius: 3px; + cursor: pointer; +} + +.editor-toolbar button { + width: 30px; +} + +.editor-toolbar button.active, +.editor-toolbar button:hover { + background: #fcfcfc; + border-color: #95a5a6; +} + +.editor-toolbar i.separator { + display: inline-block; + width: 0; + border-left: 1px solid #d9d9d9; + border-right: 1px solid #fff; + color: transparent; + text-indent: -10px; + margin: 0 6px; +} + +.editor-toolbar button:after { + font-family: Arial, "Helvetica Neue", Helvetica, sans-serif; + font-size: 65%; + vertical-align: text-bottom; + position: relative; + top: 2px; +} + +.editor-toolbar button.heading-1:after { + content: "1"; +} + +.editor-toolbar button.heading-2:after { + content: "2"; +} + +.editor-toolbar button.heading-3:after { + content: "3"; +} + +.editor-toolbar button.heading-bigger:after { + content: "▲"; +} + +.editor-toolbar button.heading-smaller:after { + content: "▼"; +} + +.editor-toolbar.disabled-for-preview button:not(.no-disable) { + opacity: 0.6; + pointer-events: none; +} + +@media only screen and (max-width: 700px) { + .editor-toolbar i.no-mobile { + display: none; + } +} + +.editor-statusbar { + padding: 8px 10px; + font-size: 12px; + color: #959694; + text-align: right; +} + +.editor-statusbar span { + display: inline-block; + min-width: 4em; + margin-left: 1em; +} + +.editor-statusbar .lines:before { + content: "lines: "; +} + +.editor-statusbar .words:before { + content: "words: "; +} + +.editor-statusbar .characters:before { + content: "characters: "; +} + +.editor-preview-full { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + z-index: 7; + overflow: auto; + display: none; + box-sizing: border-box; +} + +.editor-preview-side { + position: fixed; + bottom: 0; + width: 50%; + top: 50px; + right: 0; + z-index: 9; + overflow: auto; + display: none; + box-sizing: border-box; + border: 1px solid #ddd; + word-wrap: break-word; +} + +.editor-preview-active-side { + display: block; +} + +.editor-preview-active { + display: block; +} + +.editor-preview { + padding: 10px; + background: #fafafa; +} + +.editor-preview > p { + margin-top: 0; +} + +.editor-preview pre { + background: #eee; + margin-bottom: 10px; +} + +.editor-preview table td, +.editor-preview table th { + border: 1px solid #ddd; + padding: 5px; +} + +.cm-s-easymde .cm-tag { + color: #63a35c; +} + +.cm-s-easymde .cm-attribute { + color: #795da3; +} + +.cm-s-easymde .cm-string { + color: #183691; +} + +.cm-s-easymde .cm-header-1 { + font-size: 200%; + line-height: 200%; +} + +.cm-s-easymde .cm-header-2 { + font-size: 160%; + line-height: 160%; +} + +.cm-s-easymde .cm-header-3 { + font-size: 125%; + line-height: 125%; +} + +.cm-s-easymde .cm-header-4 { + font-size: 110%; + line-height: 110%; +} + +.cm-s-easymde .cm-comment { + background: rgba(0, 0, 0, 0.05); + border-radius: 2px; +} + +.cm-s-easymde .cm-link { + color: #7f8c8d; +} + +.cm-s-easymde .cm-url { + color: #aab2b3; +} + +.cm-s-easymde .cm-quote { + color: #7f8c8d; + font-style: italic; +} + +.editor-toolbar .easymde-dropdown { + position: relative; + background: linear-gradient( + to bottom right, + #fff 0%, + #fff 84%, + #333 50%, + #333 100% + ); + border-radius: 0; + border: 1px solid #fff; +} + +.editor-toolbar .easymde-dropdown:hover { + background: linear-gradient( + to bottom right, + #fff 0%, + #fff 84%, + #333 50%, + #333 100% + ); +} + +.easymde-dropdown-content { + display: none; + position: absolute; + background-color: #f9f9f9; + box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.2); + padding: 8px; + z-index: 2; + top: 30px; +} + +.easymde-dropdown:active .easymde-dropdown-content, +.easymde-dropdown:focus .easymde-dropdown-content { + display: block; +} diff --git a/CTFd/themes/admin/assets/css/includes/flag-icons.scss b/CTFd/themes/admin/assets/css/includes/flag-icons.scss new file mode 100644 index 0000000..0b8d791 --- /dev/null +++ b/CTFd/themes/admin/assets/css/includes/flag-icons.scss @@ -0,0 +1,511 @@ +$flags: ( + ad: "🇦🇩", + // Andorra + ae: "🇦🇪", + // United Arab Emirates + af: "🇦🇫", + // Afghanistan + ag: "🇦🇬", + // Antigua and Barbuda + ai: "🇦🇮", + // Anguilla + al: "🇦🇱", + // Albania + am: "🇦🇲", + // Armenia + ao: "🇦🇴", + // Angola + aq: "🇦🇶", + // Antarctica + ar: "🇦🇷", + // Argentina + as: "🇦🇸", + // American Samoa + at: "🇦🇹", + // Austria + au: "🇦🇺", + // Australia + aw: "🇦🇼", + // Aruba + ax: "🇦🇽", + // Åland Islands + az: "🇦🇿", + // Azerbaijan + ba: "🇧🇦", + // Bosnia and Herzegovina + bb: "🇧🇧", + // Barbados + bd: "🇧🇩", + // Bangladesh + be: "🇧🇪", + // Belgium + bf: "🇧🇫", + // Burkina Faso + bg: "🇧🇬", + // Bulgaria + bh: "🇧🇭", + // Bahrain + bi: "🇧🇮", + // Burundi + bj: "🇧🇯", + // Benin + bl: "🇧🇱", + // Saint Barthélemy + bm: "🇧🇲", + // Bermuda + bn: "🇧🇳", + // Brunei Darussalam + bo: "🇧🇴", + // Bolivia + bq: "🇧🇶", + // Bonaire, Sint Eustatius and Saba + br: "🇧🇷", + // Brazil + bs: "🇧🇸", + // Bahamas + bt: "🇧🇹", + // Bhutan + bv: "🇧🇻", + // Bouvet Island + bw: "🇧🇼", + // Botswana + by: "🇧🇾", + // Belarus + bz: "🇧🇿", + // Belize + ca: "🇨🇦", + // Canada + cc: "🇨🇨", + // Cocos (Keeling) Islands + cd: "🇨🇩", + // Congo + cf: "🇨🇫", + // Central African Republic + cg: "🇨🇬", + // Congo + ch: "🇨🇭", + // Switzerland + ci: "🇨🇮", + // Côte D'Ivoire + ck: "🇨🇰", + // Cook Islands + cl: "🇨🇱", + // Chile + cm: "🇨🇲", + // Cameroon + cn: "🇨🇳", + // China + co: "🇨🇴", + // Colombia + cr: "🇨🇷", + // Costa Rica + cu: "🇨🇺", + // Cuba + cv: "🇨🇻", + // Cape Verde + cw: "🇨🇼", + // Curaçao + cx: "🇨🇽", + // Christmas Island + cy: "🇨🇾", + // Cyprus + cz: "🇨🇿", + // Czech Republic + de: "🇩🇪", + // Germany + dj: "🇩🇯", + // Djibouti + dk: "🇩🇰", + // Denmark + dm: "🇩🇲", + // Dominica + do: "🇩🇴", + // Dominican Republic + dz: "🇩🇿", + // Algeria + ec: "🇪🇨", + // Ecuador + ee: "🇪🇪", + // Estonia + eg: "🇪🇬", + // Egypt + eh: "🇪🇭", + // Western Sahara + er: "🇪🇷", + // Eritrea + es: "🇪🇸", + // Spain + et: "🇪🇹", + // Ethiopia + fi: "🇫🇮", + // Finland + fj: "🇫🇯", + // Fiji + fk: "🇫🇰", + // Falkland Islands (Malvinas) + fm: "🇫🇲", + // Micronesia + fo: "🇫🇴", + // Faroe Islands + fr: "🇫🇷", + // France + ga: "🇬🇦", + // Gabon + gb: "🇬🇧", + // United Kingdom + gd: "🇬🇩", + // Grenada + ge: "🇬🇪", + // Georgia + gf: "🇬🇫", + // French Guiana + gg: "🇬🇬", + // Guernsey + gh: "🇬🇭", + // Ghana + gi: "🇬🇮", + // Gibraltar + gl: "🇬🇱", + // Greenland + gm: "🇬🇲", + // Gambia + gn: "🇬🇳", + // Guinea + gp: "🇬🇵", + // Guadeloupe + gq: "🇬🇶", + // Equatorial Guinea + gr: "🇬🇷", + // Greece + gs: "🇬🇸", + // South Georgia + gt: "🇬🇹", + // Guatemala + gu: "🇬🇺", + // Guam + gw: "🇬🇼", + // Guinea-Bissau + gy: "🇬🇾", + // Guyana + hk: "🇭🇰", + // Hong Kong + hm: "🇭🇲", + // Heard Island and Mcdonald Islands + hn: "🇭🇳", + // Honduras + hr: "🇭🇷", + // Croatia + ht: "🇭🇹", + // Haiti + hu: "🇭🇺", + // Hungary + id: "🇮🇩", + // Indonesia + ie: "🇮🇪", + // Ireland + il: "🇮🇱", + // Israel + im: "🇮🇲", + // Isle of Man + in: "🇮🇳", + // India + io: "🇮🇴", + // British Indian Ocean Territory + iq: "🇮🇶", + // Iraq + ir: "🇮🇷", + // Iran + is: "🇮🇸", + // Iceland + it: "🇮🇹", + // Italy + je: "🇯🇪", + // Jersey + jm: "🇯🇲", + // Jamaica + jo: "🇯🇴", + // Jordan + jp: "🇯🇵", + // Japan + ke: "🇰🇪", + // Kenya + kg: "🇰🇬", + // Kyrgyzstan + kh: "🇰🇭", + // Cambodia + ki: "🇰🇮", + // Kiribati + km: "🇰🇲", + // Comoros + kn: "🇰🇳", + // Saint Kitts and Nevis + kp: "🇰🇵", + // North Korea + kr: "🇰🇷", + // South Korea + kw: "🇰🇼", + // Kuwait + ky: "🇰🇾", + // Cayman Islands + kz: "🇰🇿", + // Kazakhstan + la: "🇱🇦", + // Lao People's Democratic Republic + lb: "🇱🇧", + // Lebanon + lc: "🇱🇨", + // Saint Lucia + li: "🇱🇮", + // Liechtenstein + lk: "🇱🇰", + // Sri Lanka + lr: "🇱🇷", + // Liberia + ls: "🇱🇸", + // Lesotho + lt: "🇱🇹", + // Lithuania + lu: "🇱🇺", + // Luxembourg + lv: "🇱🇻", + // Latvia + ly: "🇱🇾", + // Libya + ma: "🇲🇦", + // Morocco + mc: "🇲🇨", + // Monaco + md: "🇲🇩", + // Moldova + me: "🇲🇪", + // Montenegro + mf: "🇲🇫", + // Saint Martin (French Part) + mg: "🇲🇬", + // Madagascar + mh: "🇲🇭", + // Marshall Islands + mk: "🇲🇰", + // Macedonia + ml: "🇲🇱", + // Mali + mm: "🇲🇲", + // Myanmar + mn: "🇲🇳", + // Mongolia + mo: "🇲🇴", + // Macao + mp: "🇲🇵", + // Northern Mariana Islands + mq: "🇲🇶", + // Martinique + mr: "🇲🇷", + // Mauritania + ms: "🇲🇸", + // Montserrat + mt: "🇲🇹", + // Malta + mu: "🇲🇺", + // Mauritius + mv: "🇲🇻", + // Maldives + mw: "🇲🇼", + // Malawi + mx: "🇲🇽", + // Mexico + my: "🇲🇾", + // Malaysia + mz: "🇲🇿", + // Mozambique + na: "🇳🇦", + // Namibia + nc: "🇳🇨", + // New Caledonia + ne: "🇳🇪", + // Niger + nf: "🇳🇫", + // Norfolk Island + ng: "🇳🇬", + // Nigeria + ni: "🇳🇮", + // Nicaragua + nl: "🇳🇱", + // Netherlands + no: "🇳🇴", + // Norway + np: "🇳🇵", + // Nepal + nr: "🇳🇷", + // Nauru + nu: "🇳🇺", + // Niue + nz: "🇳🇿", + // New Zealand + om: "🇴🇲", + // Oman + pa: "🇵🇦", + // Panama + pe: "🇵🇪", + // Peru + pf: "🇵🇫", + // French Polynesia + pg: "🇵🇬", + // Papua New Guinea + ph: "🇵🇭", + // Philippines + pk: "🇵🇰", + // Pakistan + pl: "🇵🇱", + // Poland + pm: "🇵🇲", + // Saint Pierre and Miquelon + pn: "🇵🇳", + // Pitcairn + pr: "🇵🇷", + // Puerto Rico + ps: "🇵🇸", + // Palestinian Territory + pt: "🇵🇹", + // Portugal + pw: "🇵🇼", + // Palau + py: "🇵🇾", + // Paraguay + qa: "🇶🇦", + // Qatar + re: "🇷🇪", + // Réunion + ro: "🇷🇴", + // Romania + rs: "🇷🇸", + // Serbia + ru: "🇷🇺", + // Russia + rw: "🇷🇼", + // Rwanda + sa: "🇸🇦", + // Saudi Arabia + sb: "🇸🇧", + // Solomon Islands + sc: "🇸🇨", + // Seychelles + sd: "🇸🇩", + // Sudan + se: "🇸🇪", + // Sweden + sg: "🇸🇬", + // Singapore + sh: "🇸🇭", + // Saint Helena, Ascension and Tristan Da Cunha + si: "🇸🇮", + // Slovenia + sj: "🇸🇯", + // Svalbard and Jan Mayen + sk: "🇸🇰", + // Slovakia + sl: "🇸🇱", + // Sierra Leone + sm: "🇸🇲", + // San Marino + sn: "🇸🇳", + // Senegal + so: "🇸🇴", + // Somalia + sr: "🇸🇷", + // Suriname + ss: "🇸🇸", + // South Sudan + st: "🇸🇹", + // Sao Tome and Principe + sv: "🇸🇻", + // El Salvador + sx: "🇸🇽", + // Sint Maarten (Dutch Part) + sy: "🇸🇾", + // Syrian Arab Republic + sz: "🇸🇿", + // Swaziland + tc: "🇹🇨", + // Turks and Caicos Islands + td: "🇹🇩", + // Chad + tf: "🇹🇫", + // French Southern Territories + tg: "🇹🇬", + // Togo + th: "🇹🇭", + // Thailand + tj: "🇹🇯", + // Tajikistan + tk: "🇹🇰", + // Tokelau + tl: "🇹🇱", + // Timor-Leste + tm: "🇹🇲", + // Turkmenistan + tn: "🇹🇳", + // Tunisia + to: "🇹🇴", + // Tonga + tr: "🇹🇷", + // Turkey + tt: "🇹🇹", + // Trinidad and Tobago + tv: "🇹🇻", + // Tuvalu + tw: "🇹🇼", + // Taiwan + tz: "🇹🇿", + // Tanzania + ua: "🇺🇦", + // Ukraine + ug: "🇺🇬", + // Uganda + um: "🇺🇲", + // United States Minor Outlying Islands + us: "🇺🇸", + // United States + uy: "🇺🇾", + // Uruguay + uz: "🇺🇿", + // Uzbekistan + va: "🇻🇦", + // Vatican City + vc: "🇻🇨", + // Saint Vincent and The Grenadines + ve: "🇻🇪", + // Venezuela + vg: "🇻🇬", + // Virgin Islands, British + vi: "🇻🇮", + // Virgin Islands, U.S. + vn: "🇻🇳", + // Viet Nam + vu: "🇻🇺", + // Vanuatu + wf: "🇼🇫", + // Wallis and Futuna + ws: "🇼🇸", + // Samoa + ye: "🇾🇪", + // Yemen + yt: "🇾🇹", + // Mayotte + za: "🇿🇦", + // South Africa + zm: "🇿🇲", + // Zambia + zw: "🇿🇼", + // Zimbabwe +); + +[class^="flag-"] { + font-style: normal; +} + +// generate classes +@each $name, $icon in $flags { + .flag-#{$name}:before { + content: $icon; + } +} diff --git a/CTFd/themes/admin/assets/css/includes/jumbotron.css b/CTFd/themes/admin/assets/css/includes/jumbotron.css new file mode 100644 index 0000000..b377b0c --- /dev/null +++ b/CTFd/themes/admin/assets/css/includes/jumbotron.css @@ -0,0 +1,4 @@ +/* Move down content because we have a fixed navbar that is 3.5rem tall */ +main { + padding-top: 3.5rem; +} diff --git a/CTFd/themes/admin/assets/css/includes/min-height.scss b/CTFd/themes/admin/assets/css/includes/min-height.scss new file mode 100644 index 0000000..0741fb0 --- /dev/null +++ b/CTFd/themes/admin/assets/css/includes/min-height.scss @@ -0,0 +1,15 @@ +.min-vh-0 { + min-height: 0vh !important; +} +.min-vh-25 { + min-height: 25vh !important; +} +.min-vh-50 { + min-height: 50vh !important; +} +.min-vh-75 { + min-height: 75vh !important; +} +.min-vh-100 { + min-height: 100vh !important; +} diff --git a/CTFd/themes/admin/assets/css/includes/opacity.scss b/CTFd/themes/admin/assets/css/includes/opacity.scss new file mode 100644 index 0000000..7763e27 --- /dev/null +++ b/CTFd/themes/admin/assets/css/includes/opacity.scss @@ -0,0 +1,15 @@ +.opacity-0 { + opacity: 0 !important; +} +.opacity-25 { + opacity: 0.25 !important; +} +.opacity-50 { + opacity: 0.5 !important; +} +.opacity-75 { + opacity: 0.75 !important; +} +.opacity-100 { + opacity: 1 !important; +} diff --git a/CTFd/themes/admin/assets/css/includes/sticky-footer.css b/CTFd/themes/admin/assets/css/includes/sticky-footer.css new file mode 100644 index 0000000..940b9dc --- /dev/null +++ b/CTFd/themes/admin/assets/css/includes/sticky-footer.css @@ -0,0 +1,30 @@ +/* Sticky footer styles +-------------------------------------------------- */ +html { + position: relative; + min-height: 100%; +} + +body { + margin-bottom: 60px; /* Margin bottom by footer height */ +} + +.footer { + position: absolute; + + /* prevent scrollbars from showing on pages that don't use the full page height */ + bottom: 1px; + + width: 100%; + + /* Set the fixed height of the footer here */ + height: 60px; + + /* Override line-height from core because we have two lines in the admin panel */ + line-height: normal !important; + + /* Avoid covering things */ + z-index: -20; + + /*background-color: #f5f5f5;*/ +} diff --git a/CTFd/themes/admin/assets/css/main.scss b/CTFd/themes/admin/assets/css/main.scss new file mode 100644 index 0000000..fb7c669 --- /dev/null +++ b/CTFd/themes/admin/assets/css/main.scss @@ -0,0 +1,160 @@ +@import "~/bootstrap/scss/bootstrap.scss"; +@import "includes/jumbotron.css"; +@import "includes/sticky-footer.css"; +@import "includes/award-icons.scss"; +@import "includes/flag-icons.scss"; +@import "includes/opacity.scss"; +@import "includes/min-height.scss"; + +html, +body, +.container { + font-family: "Lato", "LatoOffline", sans-serif; +} + +h1, +h2 { + font-family: "Raleway", "RalewayOffline", sans-serif; + font-weight: 500; + letter-spacing: 2px; +} + +a { + color: #337ab7; + text-decoration: none; +} + +table > thead > tr > td { + /* Remove border line from thead of all tables */ + /* It can overlap with other element styles */ + border-top: none !important; +} + +blockquote { + border-left: 4px solid $secondary; + padding-left: 15px; +} + +.table thead th { + white-space: nowrap; +} + +.fa-spin.spinner { + text-align: center; + opacity: 0.5; +} + +.spinner-error { + padding-top: 20vh; + text-align: center; + opacity: 0.5; +} + +.jumbotron { + border-radius: 0; + text-align: center; +} + +.form-control:focus { + background-color: transparent; + border-color: #a3d39c; + box-shadow: 0 0 0 0.1rem #a3d39c; + transition: + background-color 0.3s, + border-color 0.3s; +} + +.input-filled-valid { + background-color: transparent !important; + border-color: #a3d39c; + box-shadow: 0 0 0 0.1rem #a3d39c; + transition: + background-color 0.3s, + border-color 0.3s; +} + +.input-filled-invalid { + background-color: transparent !important; + border-color: #d46767; + box-shadow: 0 0 0 0.1rem #d46767; + transition: + background-color 0.3s, + border-color 0.3s; +} + +.btn-outlined.btn-theme { + background: none; + color: #545454; + border-color: #545454; + border: 3px solid; +} + +.btn-outlined { + border-radius: 0; + -webkit-transition: all 0.3s; + -moz-transition: all 0.3s; + transition: all 0.3s; +} + +.btn { + letter-spacing: 1px; + text-decoration: none; + -moz-user-select: none; + border-radius: 0; + cursor: pointer; + display: inline-block; + margin-bottom: 0; + vertical-align: middle; + white-space: nowrap; + font-size: 14px; + line-height: 20px; + font-weight: 700; + padding: 8px 20px; +} + +.btn-info { + background-color: #5b7290 !important; + border-color: #5b7290 !important; +} + +.badge-info { + background-color: #5b7290 !important; +} + +.alert { + border-radius: 0 !important; + padding: 0.8em; +} + +.btn-fa { + cursor: pointer; +} + +.close { + cursor: pointer; +} + +.cursor-pointer { + cursor: pointer; +} + +.cursor-help { + cursor: help; +} + +.modal-content { + -webkit-border-radius: 0 !important; + -moz-border-radius: 0 !important; + border-radius: 0 !important; +} + +.fa-disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.badge-notification { + vertical-align: top; + margin-left: -1.5em; + font-size: 50%; +} diff --git a/CTFd/themes/admin/assets/js/challenges/challenge.js b/CTFd/themes/admin/assets/js/challenges/challenge.js new file mode 100644 index 0000000..18363d5 --- /dev/null +++ b/CTFd/themes/admin/assets/js/challenges/challenge.js @@ -0,0 +1,200 @@ +import $ from "jquery"; +import "../compat/json"; +import "../compat/format"; +import { ezToast, ezQuery } from "../compat/ezq"; +import { htmlEntities } from "@ctfdio/ctfd-js/utils/html"; +import CTFd from "../compat/CTFd"; +import nunjucks from "nunjucks"; + +function renderSubmissionResponse(response, cb) { + const result = response.data; + + const result_message = $("#result-message"); + const result_notification = $("#result-notification"); + const answer_input = $("#submission-input"); + result_notification.removeClass(); + result_message.text(result.message); + + if (result.status === "authentication_required") { + window.location = + CTFd.config.urlRoot + + "/login?next=" + + CTFd.config.urlRoot + + window.location.pathname + + window.location.hash; + return; + } else if (result.status === "incorrect") { + // Incorrect key + result_notification.addClass( + "alert alert-danger alert-dismissable text-center", + ); + result_notification.slideDown(); + + answer_input.removeClass("correct"); + answer_input.addClass("wrong"); + setTimeout(function () { + answer_input.removeClass("wrong"); + }, 3000); + } else if (result.status === "correct") { + // Challenge Solved + result_notification.addClass( + "alert alert-success alert-dismissable text-center", + ); + result_notification.slideDown(); + + $(".challenge-solves").text( + parseInt($(".challenge-solves").text().split(" ")[0]) + 1 + " Solves", + ); + + answer_input.val(""); + answer_input.removeClass("wrong"); + answer_input.addClass("correct"); + } else if (result.status === "already_solved") { + // Challenge already solved + result_notification.addClass( + "alert alert-info alert-dismissable text-center", + ); + result_notification.slideDown(); + + answer_input.addClass("correct"); + } else if (result.status === "paused") { + // CTF is paused + result_notification.addClass( + "alert alert-warning alert-dismissable text-center", + ); + result_notification.slideDown(); + } else if (result.status === "ratelimited") { + // Keys per minute too high + result_notification.addClass( + "alert alert-warning alert-dismissable text-center", + ); + result_notification.slideDown(); + + answer_input.addClass("too-fast"); + setTimeout(function () { + answer_input.removeClass("too-fast"); + }, 3000); + } + setTimeout(function () { + $(".alert").slideUp(); + $("#submit-key").removeClass("disabled-button"); + $("#submit-key").prop("disabled", false); + }, 3000); + + if (cb) { + cb(result); + } +} + +$(() => { + $(".preview-challenge").click(function (_event) { + window.challenge = new Object(); + $.get( + CTFd.config.urlRoot + "/api/v1/challenges/" + window.CHALLENGE_ID, + function (response) { + const challenge_data = response.data; + challenge_data["solves"] = null; + + $.getScript( + CTFd.config.urlRoot + challenge_data.type_data.scripts.view, + function () { + $.get( + CTFd.config.urlRoot + challenge_data.type_data.templates.view, + function (template_data) { + $("#challenge-window").empty(); + const template = nunjucks.compile(template_data); + window.challenge.data = challenge_data; + window.challenge.preRender(); + + challenge_data["description"] = window.challenge.render( + challenge_data["description"], + ); + challenge_data["script_root"] = CTFd.config.urlRoot; + + $("#challenge-window").append(template.render(challenge_data)); + + $(".nav-tabs a").click(function (event) { + event.preventDefault(); + $(this).tab("show"); + }); + + // Handle modal toggling + $("#challenge-window").on("hide.bs.modal", function (_event) { + $("#submission-input").removeClass("wrong"); + $("#submission-input").removeClass("correct"); + $("#incorrect-key").slideUp(); + $("#correct-key").slideUp(); + $("#already-solved").slideUp(); + $("#too-fast").slideUp(); + }); + + $("#submit-key").click(function (event) { + event.preventDefault(); + $("#submit-key").addClass("disabled-button"); + $("#submit-key").prop("disabled", true); + window.challenge.submit(function (data) { + renderSubmissionResponse(data); + }, true); + // Preview passed as true + }); + + $("#submission-input").keyup(function (event) { + if (event.keyCode == 13) { + $("#submit-key").click(); + } + }); + + window.challenge.postRender(); + window.location.replace( + window.location.href.split("#")[0] + "#preview", + ); + + $("#challenge-window").modal(); + }, + ); + }, + ); + }, + ); + }); + + $(".delete-challenge").click(function (_event) { + ezQuery({ + title: "Delete Challenge", + body: "Are you sure you want to delete {0}".format( + "" + htmlEntities(window.CHALLENGE_NAME) + "", + ), + success: function () { + CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID, { + method: "DELETE", + }).then(function (response) { + if (response.success) { + window.location = CTFd.config.urlRoot + "/admin/challenges"; + } + }); + }, + }); + }); + + $("#challenge-update-container > form").submit(function (event) { + event.preventDefault(); + const params = $(event.target).serializeJSON(true); + + CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID, { + method: "PATCH", + credentials: "same-origin", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(params), + }).then(function (data) { + if (data.success) { + ezToast({ + title: "Success", + body: "Your challenge has been updated!", + }); + } + }); + }); +}); diff --git a/CTFd/themes/admin/assets/js/challenges/new.js b/CTFd/themes/admin/assets/js/challenges/new.js new file mode 100644 index 0000000..478bc09 --- /dev/null +++ b/CTFd/themes/admin/assets/js/challenges/new.js @@ -0,0 +1,81 @@ +import CTFd from "../compat/CTFd"; +import nunjucks from "nunjucks"; +import $ from "jquery"; +import "../compat/json"; + +window.challenge = new Object(); + +function loadChalTemplate(challenge) { + $.getScript(CTFd.config.urlRoot + challenge.scripts.view, function () { + $.get( + CTFd.config.urlRoot + challenge.templates.create, + function (template_data) { + const template = nunjucks.compile(template_data); + $("#create-chal-entry-div").html( + template.render({ + nonce: CTFd.config.csrfNonce, + script_root: CTFd.config.urlRoot, + }), + ); + + $.getScript( + CTFd.config.urlRoot + challenge.scripts.create, + function () { + $("#create-chal-entry-div form").submit(function (event) { + event.preventDefault(); + const params = $("#create-chal-entry-div form").serializeJSON(); + CTFd.fetch("/api/v1/challenges", { + method: "POST", + credentials: "same-origin", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(params), + }).then(function (response) { + if (response.success) { + window.location = + CTFd.config.urlRoot + + "/admin/challenges/" + + response.data.id; + } + }); + }); + }, + ); + }, + ); + }); +} + +$.get(CTFd.config.urlRoot + "/api/v1/challenges/types", function (response) { + $("#create-chals-select").empty(); + const data = response.data; + const chal_type_amt = Object.keys(data).length; + if (chal_type_amt > 1) { + const option = ""; + $("#create-chals-select").append(option); + for (const key in data) { + const challenge = data[key]; + const option = $("