diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..dfdb8b771c --- /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 0000000000..d99572873d --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: CTFd diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7be6205e20..a0d866c8b6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,7 +10,7 @@ jobs: strategy: matrix: - python-version: ['3.9'] + python-version: ['3.11'] name: Linting steps: @@ -24,8 +24,8 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install -r linting.txt - sudo yarn install --non-interactive - sudo yarn global add prettier@1.17.0 + sudo yarn --cwd CTFd/themes/admin install --non-interactive + sudo yarn global add prettier@3.2.5 - name: Lint run: make lint @@ -39,6 +39,9 @@ jobs: - name: Lint docker-compose run: | - python -m pip install docker-compose==1.26.0 - docker-compose -f docker-compose.yml config + 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 0000000000..21cdac865f --- /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 0000000000..9ae5a258f6 --- /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 index d167a1632c..e29918d0e6 100644 --- a/.github/workflows/mysql.yml +++ b/.github/workflows/mysql.yml @@ -1,7 +1,13 @@ --- name: CTFd MySQL CI -on: [push, pull_request] +on: + push: + branches: + - master + pull_request: + branches: + - master jobs: build: @@ -22,7 +28,7 @@ jobs: strategy: matrix: - python-version: ['3.9'] + python-version: ['3.11'] name: Python ${{ matrix.python-version }} steps: @@ -48,6 +54,6 @@ jobs: TESTING_DATABASE_URL: mysql+pymysql://root:password@localhost:${{ job.services.mysql.ports[3306] }}/ctfd - name: Codecov - uses: codecov/codecov-action@v1.0.11 + 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 0000000000..49cf0d9b9a --- /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 index 3c228dad0f..15aa6f3572 100644 --- a/.github/workflows/postgres.yml +++ b/.github/workflows/postgres.yml @@ -1,7 +1,13 @@ --- name: CTFd Postgres CI -on: [push, pull_request] +on: + push: + branches: + - master + pull_request: + branches: + - master jobs: build: @@ -29,7 +35,7 @@ jobs: strategy: matrix: - python-version: ['3.9'] + python-version: ['3.11'] name: Python ${{ matrix.python-version }} steps: @@ -55,7 +61,7 @@ jobs: TESTING_DATABASE_URL: postgres://postgres:password@localhost:${{ job.services.postgres.ports[5432] }}/ctfd - name: Codecov - uses: codecov/codecov-action@v1.0.11 + uses: codecov/codecov-action@v5 with: file: ./coverage.xml diff --git a/.github/workflows/sqlite.yml b/.github/workflows/sqlite.yml index d00e683fce..64b049f308 100644 --- a/.github/workflows/sqlite.yml +++ b/.github/workflows/sqlite.yml @@ -1,7 +1,13 @@ --- name: CTFd SQLite CI -on: [push, pull_request] +on: + push: + branches: + - master + pull_request: + branches: + - master jobs: build: @@ -10,7 +16,7 @@ jobs: strategy: matrix: - python-version: ['3.9'] + python-version: ['3.11'] name: Python ${{ matrix.python-version }} steps: @@ -37,7 +43,7 @@ jobs: TESTING_DATABASE_URL: 'sqlite://' - name: Codecov - uses: codecov/codecov-action@v1.0.11 + 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 0000000000..5f5dacf1fb --- /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/.prettierignore b/.prettierignore index 6578295894..9c4da8cb74 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,8 @@ CTFd/themes/**/vendor/ +CTFd/themes/core-deprecated/ +CTFd/themes/core/static/ CTFd/themes/core-beta/**/* +CTFd/themes/admin/static/**/* *.html *.njk *.png diff --git a/CHANGELOG.md b/CHANGELOG.md index ac09c2431f..a97dff2483 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,329 @@ +# 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", path=entry) - html += f'' + i = self.manifest(theme=theme)[i]["file"] + url = url_for("views.themes_beta", theme=theme, path=i) + html += f'' + url = url_for("views.themes_beta", theme=theme, path=entry) + html += f'' return markup(html) - def css(self, asset_key): - asset = self.manifest()[asset_key] + 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", path=entry) + url = url_for("views.themes_beta", theme=theme, path=entry) return markup(f'') - def file(self, asset_key): - asset = self.manifest()[asset_key] + 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", path=entry) + return url_for("views.themes_beta", theme=theme, path=entry) Assets = _AssetsWrapper() diff --git a/CTFd/constants/config.py b/CTFd/constants/config.py index aaabb6c2ce..ba66a1285e 100644 --- a/CTFd/constants/config.py +++ b/CTFd/constants/config.py @@ -2,52 +2,18 @@ from flask import url_for -from CTFd.constants import JinjaEnum, RawEnum +# 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 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" - - class _ConfigsWrapper: def __getattr__(self, attr): return get_config(attr) @@ -96,5 +62,13 @@ def tos_link(self): 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 0000000000..7f1a907989 --- /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 index d5b058ba29..159c56541e 100644 --- a/CTFd/constants/languages.py +++ b/CTFd/constants/languages.py @@ -6,7 +6,25 @@ class Languages(str, RawEnum): GERMAN = "de" POLISH = "pl" SPANISH = "es" - CHINESE = "zh" + 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 = { @@ -14,9 +32,30 @@ class Languages(str, RawEnum): "de": "Deutsch", "pl": "Polski", "es": "Español", - "zh": "中文", + "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 0000000000..fdf2d4c773 --- /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/setup.py b/CTFd/constants/setup.py new file mode 100644 index 0000000000..b09ca31476 --- /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/teams.py b/CTFd/constants/teams.py index 16893a9ec7..ce16225ba1 100644 --- a/CTFd/constants/teams.py +++ b/CTFd/constants/teams.py @@ -1,22 +1,25 @@ 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", - [ - "id", - "oauth_id", - "name", - "email", - "secret", - "website", - "affiliation", - "country", - "bracket", - "hidden", - "banned", - "captain_id", - "created", - ], + TeamAttrsFields, + defaults=(None,) * len(TeamAttrsFields), ) diff --git a/CTFd/constants/users.py b/CTFd/constants/users.py index ab9e5fe6a5..99b667f8f0 100644 --- a/CTFd/constants/users.py +++ b/CTFd/constants/users.py @@ -1,25 +1,27 @@ 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", - [ - "id", - "oauth_id", - "name", - "email", - "type", - "secret", - "website", - "affiliation", - "country", - "bracket", - "hidden", - "banned", - "verified", - "language", - "team_id", - "created", - ], + "UserAttrs", UserAttrsFields, defaults=(None,) * len(UserAttrsFields) ) diff --git a/CTFd/exceptions/challenges.py b/CTFd/exceptions/challenges.py new file mode 100644 index 0000000000..80cf74bab3 --- /dev/null +++ b/CTFd/exceptions/challenges.py @@ -0,0 +1,6 @@ +class ChallengeCreateException(Exception): + pass + + +class ChallengeUpdateException(Exception): + pass diff --git a/CTFd/exceptions/email.py b/CTFd/exceptions/email.py new file mode 100644 index 0000000000..8e94d06fdb --- /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 0000000000..2e76eefd4e --- /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 0000000000..b7fadfa4aa Binary files /dev/null and b/CTFd/fonts/OpenSans-Bold.ttf differ diff --git a/CTFd/forms/__init__.py b/CTFd/forms/__init__.py index b6e29ce9ea..51809a2721 100644 --- a/CTFd/forms/__init__.py +++ b/CTFd/forms/__init__.py @@ -29,6 +29,7 @@ class _FormsWrapper: 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 @@ -42,6 +43,7 @@ class _FormsWrapper: Forms.submissions = submissions Forms.users = users Forms.challenges = challenges +Forms.language = language Forms.notifications = notifications Forms.config = config Forms.pages = pages diff --git a/CTFd/forms/auth.py b/CTFd/forms/auth.py index c64f59cbb6..6de356a4f4 100644 --- a/CTFd/forms/auth.py +++ b/CTFd/forms/auth.py @@ -8,28 +8,54 @@ 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"), validators=[InputRequired()], render_kw={"autofocus": True} + _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()], ) - email = EmailField(_l("Email"), validators=[InputRequired()]) - password = PasswordField(_l("Password"), 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) + 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) @@ -45,7 +71,7 @@ class LoginForm(BaseForm): class ConfirmForm(BaseForm): - submit = SubmitField(_l("Resend Confirmation Email")) + submit = SubmitField(_l("Send Confirmation Email")) class ResetPasswordRequestForm(BaseForm): diff --git a/CTFd/forms/config.py b/CTFd/forms/config.py index 55dfec51ab..d182e62efb 100644 --- a/CTFd/forms/config.py +++ b/CTFd/forms/config.py @@ -8,9 +8,23 @@ 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): @@ -36,8 +50,12 @@ class ResetInstanceForm(BaseForm): class AccountSettingsForm(BaseForm): domain_whitelist = StringField( - "Account Email Whitelist", - description="Comma-seperated email domains which users can register under (e.g. ctfd.io, example.com, *.example.com)", + "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", @@ -47,16 +65,22 @@ class AccountSettingsForm(BaseForm): ) team_size = IntegerField( widget=NumberInput(min=0), - description="Amount of users per team (Teams mode only)", + description="Maximum number of users per team (Teams mode only)", ) num_teams = IntegerField( - "Total Number of Teams", + "Maximum Number of Teams", widget=NumberInput(min=0), - description="Max number of teams (Teams mode only)", + 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="Max number of users", + description="Minimum Password Length for Users", ) verify_emails = SelectField( "Verify Emails", @@ -82,7 +106,7 @@ class AccountSettingsForm(BaseForm): incorrect_submissions_per_min = IntegerField( "Incorrect Submissions per Minute", widget=NumberInput(min=1), - description="Amount of submissions allowed per minute for flag bruteforce protection (default: 10)", + description="Number of submissions allowed per minute for flag bruteforce protection (default: 10)", ) submit = SubmitField("Update") @@ -102,6 +126,21 @@ class ImportCSVForm(BaseForm): 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", @@ -122,6 +161,48 @@ class LegalSettingsForm(BaseForm): 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", @@ -164,3 +245,100 @@ class VisibilitySettingsForm(BaseForm): ], 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/language.py b/CTFd/forms/language.py new file mode 100644 index 0000000000..00c7939461 --- /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/pages.py b/CTFd/forms/pages.py index 3069b59340..041776763c 100644 --- a/CTFd/forms/pages.py +++ b/CTFd/forms/pages.py @@ -30,6 +30,13 @@ class PageEditForm(BaseForm): 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): diff --git a/CTFd/forms/self.py b/CTFd/forms/self.py index d325bc5d62..1394e3f085 100644 --- a/CTFd/forms/self.py +++ b/CTFd/forms/self.py @@ -6,9 +6,14 @@ 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, build_custom_user_fields +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 +from CTFd.utils.user import get_current_user, get_current_user_attrs def SettingsForm(*args, **kwargs): @@ -25,13 +30,14 @@ class _SettingsForm(BaseForm): @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(): @@ -44,6 +50,7 @@ def get_field_kwargs(): field_kwargs = _SettingsForm.get_field_kwargs() attach_custom_user_fields(_SettingsForm, **field_kwargs) + attach_user_bracket_field(_SettingsForm) return _SettingsForm(*args, **kwargs) diff --git a/CTFd/forms/setup.py b/CTFd/forms/setup.py index f35e413c38..7a0d1f2fe3 100644 --- a/CTFd/forms/setup.py +++ b/CTFd/forms/setup.py @@ -19,6 +19,7 @@ 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 @@ -76,8 +77,7 @@ class SetupForm(BaseForm): _l("Theme"), description=_l("CTFd Theme to use. Can be changed later."), choices=list(zip(get_themes(), get_themes())), - ## TODO: Replace back to DEFAULT_THEME (aka core) in CTFd 4.0 - default="core-beta", + default=DEFAULT_THEME, validators=[InputRequired()], ) theme_color = HiddenField( @@ -147,4 +147,11 @@ class SetupForm(BaseForm): _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/teams.py b/CTFd/forms/teams.py index ea42e25c02..3d3a25575b 100644 --- a/CTFd/forms/teams.py +++ b/CTFd/forms/teams.py @@ -5,11 +5,37 @@ from CTFd.forms import BaseForm from CTFd.forms.fields import SubmitField -from CTFd.models import TeamFieldEntries, TeamFields +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, @@ -88,9 +114,10 @@ class _TeamRegisterForm(BaseForm): 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) @@ -216,9 +243,12 @@ class _TeamCreateForm(TeamBaseForm): @property def extra(self): - return build_custom_team_fields(self, include_entries=False) + 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) @@ -234,7 +264,7 @@ def extra(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): """ @@ -246,6 +276,7 @@ def __init__(self, *args, **kwargs): self.obj = obj attach_custom_team_fields(_TeamEditForm) + attach_team_bracket_field(_TeamEditForm) return _TeamEditForm(*args, **kwargs) diff --git a/CTFd/forms/users.py b/CTFd/forms/users.py index 70a4bb0c28..ab47d7ecb0 100644 --- a/CTFd/forms/users.py +++ b/CTFd/forms/users.py @@ -7,7 +7,7 @@ from CTFd.constants.languages import SELECT_LANGUAGE_LIST from CTFd.forms import BaseForm from CTFd.forms.fields import SubmitField -from CTFd.models import UserFieldEntries, UserFields +from CTFd.models import Brackets, UserFieldEntries, UserFields from CTFd.utils.countries import SELECT_COUNTRIES_LIST @@ -88,7 +88,7 @@ def build_registration_code_field(form_cls): Add field_type so Jinja knows how to render it. """ if Configs.registration_code: - field = getattr(form_cls, "registration_code") # noqa B009 + field = getattr(form_cls, "registration_code", None) # noqa B009 field.field_type = "text" return [field] else: @@ -112,6 +112,32 @@ def attach_registration_code_field(form_cls): ) +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", @@ -161,6 +187,7 @@ class UserBaseForm(BaseForm): verified = BooleanField("Verified") hidden = BooleanField("Hidden") banned = BooleanField("Banned") + change_password = BooleanField("Require password change on next login") submit = SubmitField("Submit") @@ -175,7 +202,7 @@ def extra(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): """ @@ -187,6 +214,7 @@ def __init__(self, *args, **kwargs): self.obj = obj attach_custom_user_fields(_UserEditForm) + attach_user_bracket_field(_UserEditForm) return _UserEditForm(*args, **kwargs) @@ -197,8 +225,11 @@ class _UserCreateForm(UserBaseForm): @property def extra(self): - return build_custom_user_fields(self, include_entries=False) + 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/models/__init__.py b/CTFd/models/__init__.py index 86538bfa39..739e862922 100644 --- a/CTFd/models/__init__.py +++ b/CTFd/models/__init__.py @@ -20,11 +20,26 @@ def get_class_by_tablename(tablename): :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: - return c - return None + 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") @@ -70,7 +85,7 @@ class Pages(db.Model): hidden = db.Column(db.Boolean) auth_required = db.Column(db.Boolean) format = db.Column(db.String(80), default="markdown") - # TODO: Use hidden attribute + link_target = db.Column(db.String(80), nullable=True) files = db.relationship("PageFiles", backref="page") @@ -97,6 +112,7 @@ class Challenges(db.Model): 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) @@ -104,6 +120,12 @@ class Challenges(db.Model): 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") @@ -112,6 +134,8 @@ class Challenges(db.Model): 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): """ @@ -130,6 +154,13 @@ def __missing__(self, key): "_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 @@ -137,6 +168,12 @@ def html(self): 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 @@ -153,6 +190,7 @@ def __repr__(self): 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") @@ -268,11 +306,35 @@ 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} @@ -303,6 +365,11 @@ 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) @@ -339,15 +406,21 @@ class Users(db.Model): website = db.Column(db.String(128)) affiliation = db.Column(db.String(128)) country = db.Column(db.String(32)) - bracket = 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", @@ -406,7 +479,12 @@ def awards(self): @property def score(self): - return self.get_score(admin=False) + from CTFd.utils.config.visibility import scores_visible + + if scores_visible(): + return self.get_score(admin=False) + else: + return None @property def place(self): @@ -431,7 +509,12 @@ def filled_all_required_fields(self): .filter_by(user_id=self.id) .all() } - return required_user_fields.issubset(submitted_user_fields) + # 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: @@ -512,8 +595,8 @@ def get_place(self, admin=False, numeric=False): to no imports within the CTFd application as importing from the application itself will result in a circular import. """ - from CTFd.utils.scores import get_user_standings # noqa: I001 from CTFd.utils.humanize.numbers import ordinalize + from CTFd.utils.scores import get_user_standings standings = get_user_standings(admin=admin) @@ -552,7 +635,9 @@ class Teams(db.Model): website = db.Column(db.String(128)) affiliation = db.Column(db.String(128)) country = db.Column(db.String(32)) - bracket = 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) @@ -560,6 +645,9 @@ class Teams(db.Model): 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", @@ -596,7 +684,12 @@ def awards(self): @property def score(self): - return self.get_score(admin=False) + from CTFd.utils.config.visibility import scores_visible + + if scores_visible(): + return self.get_score(admin=False) + else: + return None @property def place(self): @@ -621,7 +714,11 @@ def filled_all_required_fields(self): .filter_by(team_id=self.id) .all() } - return required_team_fields.issubset(submitted_team_fields) + 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: @@ -633,14 +730,17 @@ def get_fields(self, admin=False): def get_invite_code(self): from flask import current_app # noqa: I001 - from CTFd.utils.security.signing import serialize, hmac + + 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") - team_password_key = self.password.encode("utf-8") - verification_secret = secret_key + team_password_key + 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, @@ -652,13 +752,14 @@ def get_invite_code(self): @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 ( - unserialize, - hmac, - BadTimeSignature, BadSignature, + BadTimeSignature, + hmac, + unserialize, ) - from CTFd.exceptions import TeamTokenExpiredException, TeamTokenInvalidException secret_key = current_app.config["SECRET_KEY"] if isinstance(secret_key, str): @@ -678,8 +779,10 @@ def load_invite_code(cls, code): team = cls.query.filter_by(id=team_id).first_or_404() # Create the team specific secret - team_password_key = team.password.encode("utf-8") - verification_secret = secret_key + team_password_key + 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"] @@ -750,8 +853,8 @@ def get_place(self, admin=False, numeric=False): to no imports within the CTFd application as importing from the application itself will result in a circular import. """ - from CTFd.utils.scores import get_team_standings # noqa: I001 from CTFd.utils.humanize.numbers import ordinalize + from CTFd.utils.scores import get_team_standings # noqa: I001 standings = get_team_standings(admin=admin) @@ -855,6 +958,10 @@ class Fails(Submissions): __mapper_args__ = {"polymorphic_identity": "incorrect"} +class Partials(Submissions): + __mapper_args__ = {"polymorphic_identity": "partial"} + + class Discards(Submissions): __mapper_args__ = {"polymorphic_identity": "discard"} @@ -888,11 +995,16 @@ 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) @@ -1044,3 +1156,36 @@ class TeamFieldEntries(FieldEntries): 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 index 30ac1f11df..93094dc204 100644 --- a/CTFd/plugins/__init__.py +++ b/CTFd/plugins/__init__.py @@ -18,7 +18,7 @@ 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"]) +Menu = namedtuple("Menu", ["title", "route", "link_target"]) def register_plugin_assets_directory(app, base_path, admins_only=False, endpoint=None): @@ -55,7 +55,7 @@ def register_plugin_asset(app, asset_path, admins_only=False, endpoint=None): endpoint = asset_path.replace("/", ".") def asset_handler(): - return send_file(asset_path) + return send_file(asset_path, max_age=3600) if admins_only: asset_handler = admins_only_wrapper(asset_handler) @@ -106,7 +106,7 @@ def register_admin_plugin_stylesheet(*args, **kwargs): utils_register_admin_plugin_stylesheet(*args, **kwargs) -def register_admin_plugin_menu_bar(title, route): +def register_admin_plugin_menu_bar(title, route, link_target=None): """ Registers links on the Admin Panel menubar/navbar @@ -114,7 +114,7 @@ def register_admin_plugin_menu_bar(title, route): :param route: A string that is the href used by the link :return: """ - am = Menu(title=title, route=route) + am = Menu(title=title, route=route, link_target=link_target) app.admin_plugin_menu_bar.append(am) @@ -127,7 +127,7 @@ def get_admin_plugin_menu_bar(): return app.admin_plugin_menu_bar -def register_user_page_menu_bar(title, route): +def register_user_page_menu_bar(title, route, link_target=None): """ Registers links on the User side menubar/navbar @@ -135,7 +135,7 @@ def register_user_page_menu_bar(title, route): :param route: A string that is the href used by the link :return: """ - p = Menu(title=title, route=route) + p = Menu(title=title, route=route, link_target=link_target) app.plugin_menu_bar.append(p) @@ -151,7 +151,7 @@ def get_user_page_menu_bar(): route = p.route else: route = url_for("views.static_html", route=p.route) - pages.append(Menu(title=p.title, route=route)) + pages.append(Menu(title=p.title, route=route, link_target=p.link_target)) return pages diff --git a/CTFd/plugins/challenges/__init__.py b/CTFd/plugins/challenges/__init__.py index 79e701e639..d653a19683 100644 --- a/CTFd/plugins/challenges/__init__.py +++ b/CTFd/plugins/challenges/__init__.py @@ -1,21 +1,54 @@ +from dataclasses import dataclass + from flask import Blueprint +from CTFd.exceptions.challenges import ( + ChallengeCreateException, + ChallengeUpdateException, +) from CTFd.models import ( ChallengeFiles, Challenges, Fails, Flags, Hints, + Partials, Solves, Tags, db, ) from CTFd.plugins import register_plugin_assets_directory -from CTFd.plugins.flags import FlagException, get_flag_class +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 @@ -35,9 +68,24 @@ def create(cls, request): 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 @@ -53,11 +101,17 @@ def read(cls, challenge): "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, @@ -80,9 +134,32 @@ 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"): + 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 @@ -119,14 +196,31 @@ 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() - for flag in flags: - try: - if get_flag_class(flag.type).compare(flag, submission): - return True, "Correct" - except FlagException as e: - return False, str(e) - return False, "Incorrect" + + 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 solve(cls, user, team, challenge, request): @@ -150,6 +244,10 @@ def solve(cls, user, team, challenge, request): db.session.add(solve) db.session.commit() + # 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): """ diff --git a/CTFd/plugins/challenges/decay.py b/CTFd/plugins/challenges/decay.py new file mode 100644 index 0000000000..8db468f9ef --- /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 0000000000..b8260653ba --- /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/dynamic_challenges/__init__.py b/CTFd/plugins/dynamic_challenges/__init__.py index 94ebe20a94..45f51e38fc 100644 --- a/CTFd/plugins/dynamic_challenges/__init__.py +++ b/CTFd/plugins/dynamic_challenges/__init__.py @@ -1,5 +1,9 @@ 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 @@ -12,14 +16,49 @@ class DynamicChallenge(Challenges): id = db.Column( db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE"), primary_key=True ) - initial = db.Column(db.Integer, default=0) - minimum = db.Column(db.Integer, default=0) - decay = db.Column(db.Integer, default=0) - function = db.Column(db.String(32), default="logarithmic") + 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) - self.value = kwargs["initial"] + try: + self.value = kwargs["initial"] + except KeyError: + raise ChallengeCreateException("Missing initial value for challenge") class DynamicValueChallenge(BaseChallenge): @@ -66,26 +105,15 @@ def read(cls, challenge): :return: Challenge object, data dictionary to be returned to the user """ challenge = DynamicChallenge.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, - "connection_info": challenge.connection_info, - "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, - }, - } + data = super().read(challenge) + data.update( + { + "initial": challenge.initial, + "decay": challenge.decay, + "minimum": challenge.minimum, + "function": challenge.function, + } + ) return data @classmethod @@ -103,7 +131,10 @@ def update(cls, challenge, request): 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) + try: + value = float(value) + except (ValueError, TypeError): + raise ChallengeUpdateException(f"Invalid input for '{attr}'") setattr(challenge, attr, value) return DynamicValueChallenge.calculate_value(challenge) diff --git a/CTFd/plugins/dynamic_challenges/assets/update.html b/CTFd/plugins/dynamic_challenges/assets/update.html index 827aa32218..9c92afaaca 100644 --- a/CTFd/plugins/dynamic_challenges/assets/update.html +++ b/CTFd/plugins/dynamic_challenges/assets/update.html @@ -56,4 +56,16 @@ +{% 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/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 0000000000..0f7e554925 --- /dev/null +++ b/CTFd/plugins/dynamic_challenges/migrations/93284ed9c099_add_dynamic_prefix_to_dynamic_challenge_.py @@ -0,0 +1,113 @@ +"""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 + +# revision identifiers, used by Alembic. +revision = "93284ed9c099" +down_revision = "eb68f277ab61" +branch_labels = None +depends_on = None + + +def upgrade(op=None): + # Add new columns with dynamic_ prefix + op.add_column( + "dynamic_challenge", sa.Column("dynamic_initial", sa.Integer(), nullable=True) + ) + op.add_column( + "dynamic_challenge", sa.Column("dynamic_minimum", sa.Integer(), nullable=True) + ) + op.add_column( + "dynamic_challenge", sa.Column("dynamic_decay", sa.Integer(), nullable=True) + ) + 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 + op.drop_column("dynamic_challenge", "minimum") + op.drop_column("dynamic_challenge", "initial") + op.drop_column("dynamic_challenge", "function") + op.drop_column("dynamic_challenge", "decay") + + +def downgrade(op=None): + # Add old columns back + op.add_column("dynamic_challenge", sa.Column("decay", sa.Integer(), nullable=True)) + op.add_column( + "dynamic_challenge", sa.Column("function", sa.String(length=32), nullable=True) + ) + op.add_column( + "dynamic_challenge", sa.Column("initial", sa.Integer(), nullable=True) + ) + 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 + op.drop_column("dynamic_challenge", "dynamic_function") + op.drop_column("dynamic_challenge", "dynamic_decay") + op.drop_column("dynamic_challenge", "dynamic_minimum") + op.drop_column("dynamic_challenge", "dynamic_initial") diff --git a/CTFd/themes/admin/static/js/core.min.js b/CTFd/plugins/flags/tests/__init__.py similarity index 100% rename from CTFd/themes/admin/static/js/core.min.js rename to CTFd/plugins/flags/tests/__init__.py diff --git a/CTFd/plugins/flags/tests/test_flags.py b/CTFd/plugins/flags/tests/test_flags.py new file mode 100644 index 0000000000..04d3a9b4e1 --- /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/schemas/brackets.py b/CTFd/schemas/brackets.py new file mode 100644 index 0000000000..f8b690516e --- /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/hints.py b/CTFd/schemas/hints.py index da06d00b42..32fbab0991 100644 --- a/CTFd/schemas/hints.py +++ b/CTFd/schemas/hints.py @@ -9,9 +9,10 @@ class Meta: dump_only = ("id", "type", "html") views = { - "locked": ["id", "type", "challenge", "challenge_id", "cost"], + "locked": ["id", "title", "type", "challenge", "challenge_id", "cost"], "unlocked": [ "id", + "title", "type", "challenge", "challenge_id", @@ -21,6 +22,7 @@ class Meta: ], "admin": [ "id", + "title", "type", "challenge", "challenge_id", diff --git a/CTFd/schemas/pages.py b/CTFd/schemas/pages.py index 30160c84b6..16a6c1e8c5 100644 --- a/CTFd/schemas/pages.py +++ b/CTFd/schemas/pages.py @@ -49,6 +49,18 @@ class Meta: ], ) + 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") diff --git a/CTFd/schemas/ratings.py b/CTFd/schemas/ratings.py new file mode 100644 index 0000000000..ddd677ea05 --- /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 0000000000..390cf54771 --- /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 index 8b439f0479..62ef1eca66 100644 --- a/CTFd/schemas/submissions.py +++ b/CTFd/schemas/submissions.py @@ -30,6 +30,16 @@ class Meta: "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): diff --git a/CTFd/schemas/teams.py b/CTFd/schemas/teams.py index 208369c72a..eb3de624a8 100644 --- a/CTFd/schemas/teams.py +++ b/CTFd/schemas/teams.py @@ -3,7 +3,7 @@ from marshmallow_sqlalchemy import field_for from sqlalchemy.orm import load_only -from CTFd.models import TeamFieldEntries, TeamFields, Teams, Users, ma +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 @@ -48,6 +48,7 @@ class Meta: ], ) 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" ) @@ -143,6 +144,9 @@ def validate_password_confirmation(self, data): 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"] @@ -200,6 +204,37 @@ def validate_captain_id(self, data): 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): """ @@ -329,7 +364,7 @@ def process_fields(self, data): "name", "country", "affiliation", - "bracket", + "bracket_id", "members", "id", "oauth_id", @@ -342,7 +377,7 @@ def process_fields(self, data): "email", "country", "affiliation", - "bracket", + "bracket_id", "members", "id", "oauth_id", @@ -359,7 +394,7 @@ def process_fields(self, data): "email", "affiliation", "secret", - "bracket", + "bracket_id", "members", "hidden", "id", diff --git a/CTFd/schemas/users.py b/CTFd/schemas/users.py index a398c37552..0fba3503e3 100644 --- a/CTFd/schemas/users.py +++ b/CTFd/schemas/users.py @@ -3,11 +3,11 @@ from marshmallow_sqlalchemy import field_for from sqlalchemy.orm import load_only -from CTFd.models import UserFieldEntries, UserFields, Users, ma +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_whitelisted +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 @@ -53,6 +53,7 @@ class Meta: 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" ) @@ -154,6 +155,11 @@ def validate_email(self, data): "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 @@ -166,12 +172,23 @@ def validate_password_confirmation(self, data): 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 ) @@ -185,6 +202,37 @@ def validate_password_confirmation(self, data): 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): """ @@ -314,7 +362,7 @@ def process_fields(self, data): "name", "country", "affiliation", - "bracket", + "bracket_id", "id", "oauth_id", "fields", @@ -327,7 +375,7 @@ def process_fields(self, data): "language", "country", "affiliation", - "bracket", + "bracket_id", "id", "oauth_id", "password", @@ -344,13 +392,14 @@ def process_fields(self, data): "language", "affiliation", "secret", - "bracket", + "bracket_id", "hidden", "id", "oauth_id", "password", "type", "verified", + "change_password", "fields", "team_id", ], diff --git a/CTFd/share.py b/CTFd/share.py new file mode 100644 index 0000000000..d991a94401 --- /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 index 09372184f4..192d5768f7 100644 --- a/CTFd/teams.py +++ b/CTFd/teams.py @@ -2,7 +2,7 @@ from CTFd.cache import clear_team_session, clear_user_session from CTFd.exceptions import TeamTokenExpiredException, TeamTokenInvalidException -from CTFd.models import TeamFieldEntries, TeamFields, Teams, db +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 @@ -37,7 +37,7 @@ def listing(): Teams.query.filter_by(hidden=False, banned=False) .filter(*filters) .order_by(Teams.id.asc()) - .paginate(per_page=50) + .paginate(per_page=50, error_out=False) ) args = dict(request.args) @@ -228,6 +228,7 @@ def new(): 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() @@ -264,6 +265,16 @@ def new(): 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) @@ -279,6 +290,8 @@ def new(): 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 @@ -289,7 +302,11 @@ def new(): hidden = True team = Teams( - name=teamname, password=passphrase, captain_id=user.id, hidden=hidden + name=teamname, + password=passphrase, + captain_id=user.id, + hidden=hidden, + bracket_id=bracket_id, ) if website: @@ -334,7 +351,7 @@ def private(): awards = team.get_awards() place = team.place - score = team.score + score = team.get_score(admin=True) if config.is_scoreboard_frozen(): infos.append("Scoreboard has been frozen") diff --git a/CTFd/themes/admin/assets/css/admin.scss b/CTFd/themes/admin/assets/css/admin.scss index 9630d5e7c7..a76423406f 100644 --- a/CTFd/themes/admin/assets/css/admin.scss +++ b/CTFd/themes/admin/assets/css/admin.scss @@ -1,5 +1,32 @@ @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; @@ -21,6 +48,11 @@ display: block; } +#points-pie-graph { + min-height: 400px; + display: block; +} + #solve-percentages-graph { min-height: 400px; display: block; @@ -84,7 +116,9 @@ input[type="checkbox"] { background-color: transparent !important; border-color: #a3d39c; box-shadow: 0 0 0 0.1rem #a3d39c; - transition: background-color 0.3s, border-color 0.3s; + transition: + background-color 0.3s, + border-color 0.3s; } .card-radio:checked + .card .card-radio-clone { visibility: visible !important; diff --git a/CTFd/themes/admin/assets/css/challenge-board.scss b/CTFd/themes/admin/assets/css/challenge-board.scss index 2f2df40a5e..ef6bbbd14d 100644 --- a/CTFd/themes/admin/assets/css/challenge-board.scss +++ b/CTFd/themes/admin/assets/css/challenge-board.scss @@ -62,5 +62,7 @@ background-color: transparent; border-color: #a3d39c; box-shadow: 0 0 0 0.1rem #a3d39c; - transition: background-color 0.3s, border-color 0.3s; + 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 index 97b02ddb55..09b6af44df 100644 --- a/CTFd/themes/admin/assets/css/codemirror.scss +++ b/CTFd/themes/admin/assets/css/codemirror.scss @@ -1,4 +1,4 @@ -@import "~codemirror/lib/codemirror.css"; +@import "~/codemirror/lib/codemirror.css"; @import "includes/easymde.scss"; .CodeMirror.cm-s-default { font-size: 12px; diff --git a/CTFd/themes/admin/assets/css/fonts.scss b/CTFd/themes/admin/assets/css/fonts.scss new file mode 100644 index 0000000000..145309ba82 --- /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/core/assets/css/includes/award-icons.scss b/CTFd/themes/admin/assets/css/includes/award-icons.scss similarity index 100% rename from CTFd/themes/core/assets/css/includes/award-icons.scss rename to CTFd/themes/admin/assets/css/includes/award-icons.scss diff --git a/CTFd/themes/core-beta/assets/scss/includes/icons/_flag-icons.scss b/CTFd/themes/admin/assets/css/includes/flag-icons.scss similarity index 100% rename from CTFd/themes/core-beta/assets/scss/includes/icons/_flag-icons.scss rename to CTFd/themes/admin/assets/css/includes/flag-icons.scss diff --git a/CTFd/themes/core/assets/css/includes/jumbotron.css b/CTFd/themes/admin/assets/css/includes/jumbotron.css similarity index 100% rename from CTFd/themes/core/assets/css/includes/jumbotron.css rename to CTFd/themes/admin/assets/css/includes/jumbotron.css diff --git a/CTFd/themes/core/assets/css/includes/utils/min-height.scss b/CTFd/themes/admin/assets/css/includes/min-height.scss similarity index 100% rename from CTFd/themes/core/assets/css/includes/utils/min-height.scss rename to CTFd/themes/admin/assets/css/includes/min-height.scss diff --git a/CTFd/themes/core/assets/css/includes/utils/opacity.scss b/CTFd/themes/admin/assets/css/includes/opacity.scss similarity index 100% rename from CTFd/themes/core/assets/css/includes/utils/opacity.scss rename to CTFd/themes/admin/assets/css/includes/opacity.scss diff --git a/CTFd/themes/admin/assets/css/main.scss b/CTFd/themes/admin/assets/css/main.scss new file mode 100644 index 0000000000..fb7c669fbb --- /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 index 05c9deeeb2..18363d56f0 100644 --- a/CTFd/themes/admin/assets/js/challenges/challenge.js +++ b/CTFd/themes/admin/assets/js/challenges/challenge.js @@ -1,7 +1,9 @@ import $ from "jquery"; -import { ezToast, ezQuery } from "core/ezq"; -import { htmlEntities } from "core/utils"; -import CTFd from "core/CTFd"; +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) { @@ -24,30 +26,24 @@ function renderSubmissionResponse(response, cb) { } else if (result.status === "incorrect") { // Incorrect key result_notification.addClass( - "alert alert-danger alert-dismissable text-center" + "alert alert-danger alert-dismissable text-center", ); result_notification.slideDown(); answer_input.removeClass("correct"); answer_input.addClass("wrong"); - setTimeout(function() { + setTimeout(function () { answer_input.removeClass("wrong"); }, 3000); } else if (result.status === "correct") { // Challenge Solved result_notification.addClass( - "alert alert-success alert-dismissable text-center" + "alert alert-success alert-dismissable text-center", ); result_notification.slideDown(); $(".challenge-solves").text( - parseInt( - $(".challenge-solves") - .text() - .split(" ")[0] - ) + - 1 + - " Solves" + parseInt($(".challenge-solves").text().split(" ")[0]) + 1 + " Solves", ); answer_input.val(""); @@ -56,7 +52,7 @@ function renderSubmissionResponse(response, cb) { } else if (result.status === "already_solved") { // Challenge already solved result_notification.addClass( - "alert alert-info alert-dismissable text-center" + "alert alert-info alert-dismissable text-center", ); result_notification.slideDown(); @@ -64,22 +60,22 @@ function renderSubmissionResponse(response, cb) { } else if (result.status === "paused") { // CTF is paused result_notification.addClass( - "alert alert-warning alert-dismissable text-center" + "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" + "alert alert-warning alert-dismissable text-center", ); result_notification.slideDown(); answer_input.addClass("too-fast"); - setTimeout(function() { + setTimeout(function () { answer_input.removeClass("too-fast"); }, 3000); } - setTimeout(function() { + setTimeout(function () { $(".alert").slideUp(); $("#submit-key").removeClass("disabled-button"); $("#submit-key").prop("disabled", false); @@ -91,39 +87,39 @@ function renderSubmissionResponse(response, cb) { } $(() => { - $(".preview-challenge").click(function(_event) { + $(".preview-challenge").click(function (_event) { window.challenge = new Object(); $.get( CTFd.config.urlRoot + "/api/v1/challenges/" + window.CHALLENGE_ID, - function(response) { + function (response) { const challenge_data = response.data; challenge_data["solves"] = null; $.getScript( CTFd.config.urlRoot + challenge_data.type_data.scripts.view, - function() { + function () { $.get( CTFd.config.urlRoot + challenge_data.type_data.templates.view, - function(template_data) { + 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["description"], ); challenge_data["script_root"] = CTFd.config.urlRoot; $("#challenge-window").append(template.render(challenge_data)); - $(".nav-tabs a").click(function(event) { + $(".nav-tabs a").click(function (event) { event.preventDefault(); $(this).tab("show"); }); // Handle modal toggling - $("#challenge-window").on("hide.bs.modal", function(_event) { + $("#challenge-window").on("hide.bs.modal", function (_event) { $("#submission-input").removeClass("wrong"); $("#submission-input").removeClass("correct"); $("#incorrect-key").slideUp(); @@ -132,17 +128,17 @@ $(() => { $("#too-fast").slideUp(); }); - $("#submit-key").click(function(event) { + $("#submit-key").click(function (event) { event.preventDefault(); $("#submit-key").addClass("disabled-button"); $("#submit-key").prop("disabled", true); - window.challenge.submit(function(data) { + window.challenge.submit(function (data) { renderSubmissionResponse(data); }, true); // Preview passed as true }); - $("#submission-input").keyup(function(event) { + $("#submission-input").keyup(function (event) { if (event.keyCode == 13) { $("#submit-key").click(); } @@ -150,37 +146,37 @@ $(() => { window.challenge.postRender(); window.location.replace( - window.location.href.split("#")[0] + "#preview" + window.location.href.split("#")[0] + "#preview", ); $("#challenge-window").modal(); - } + }, ); - } + }, ); - } + }, ); }); - $(".delete-challenge").click(function(_event) { + $(".delete-challenge").click(function (_event) { ezQuery({ title: "Delete Challenge", body: "Are you sure you want to delete {0}".format( - "" + htmlEntities(window.CHALLENGE_NAME) + "" + "" + htmlEntities(window.CHALLENGE_NAME) + "", ), - success: function() { + success: function () { CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID, { - method: "DELETE" - }).then(function(response) { + method: "DELETE", + }).then(function (response) { if (response.success) { window.location = CTFd.config.urlRoot + "/admin/challenges"; } }); - } + }, }); }); - $("#challenge-update-container > form").submit(function(event) { + $("#challenge-update-container > form").submit(function (event) { event.preventDefault(); const params = $(event.target).serializeJSON(true); @@ -189,14 +185,14 @@ $(() => { credentials: "same-origin", headers: { Accept: "application/json", - "Content-Type": "application/json" + "Content-Type": "application/json", }, - body: JSON.stringify(params) - }).then(function(data) { + body: JSON.stringify(params), + }).then(function (data) { if (data.success) { ezToast({ title: "Success", - body: "Your challenge has been updated!" + 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 index 189958628f..478bc091ea 100644 --- a/CTFd/themes/admin/assets/js/challenges/new.js +++ b/CTFd/themes/admin/assets/js/challenges/new.js @@ -1,47 +1,54 @@ -import CTFd from "core/CTFd"; +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.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; - } - }); - }); - }); - }); + $.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) { +$.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; @@ -65,9 +72,7 @@ $.get(CTFd.config.urlRoot + "/api/v1/challenges/types", function(response) { }); function createChallenge(_event) { - const challenge = $(this) - .find("option:selected") - .data("meta"); + const challenge = $(this).find("option:selected").data("meta"); loadChalTemplate(challenge); } diff --git a/CTFd/themes/admin/assets/js/challenges/tags.js b/CTFd/themes/admin/assets/js/challenges/tags.js index e0d8d173c1..c531ff71cb 100644 --- a/CTFd/themes/admin/assets/js/challenges/tags.js +++ b/CTFd/themes/admin/assets/js/challenges/tags.js @@ -1,11 +1,12 @@ import $ from "jquery"; -import CTFd from "core/CTFd"; +import "../compat/format"; +import CTFd from "../compat/CTFd"; export function deleteTag(_event) { const $elem = $(this); const tag_id = $elem.attr("tag-id"); - CTFd.api.delete_tag({ tagId: tag_id }).then(response => { + CTFd.api.delete_tag({ tagId: tag_id }).then((response) => { if (response.success) { $elem.parent().remove(); } @@ -22,10 +23,10 @@ export function addTag(event) { const tag = $elem.val(); const params = { value: tag, - challenge: window.CHALLENGE_ID + challenge: window.CHALLENGE_ID, }; - CTFd.api.post_tag_list({}, params).then(response => { + CTFd.api.post_tag_list({}, params).then((response) => { if (response.success) { const tpl = "" + diff --git a/CTFd/themes/admin/assets/js/compat/CTFd.js b/CTFd/themes/admin/assets/js/compat/CTFd.js new file mode 100644 index 0000000000..9e9703061e --- /dev/null +++ b/CTFd/themes/admin/assets/js/compat/CTFd.js @@ -0,0 +1,77 @@ +import $ from "jquery"; +import dayjs from "dayjs"; +import MarkdownIt from "markdown-it"; + +import "./patch"; +import fetch from "./fetch"; +import config from "./config"; +import API from "./api"; +import ezq from "./ezq"; +import { htmlEntities, createHtmlNode } from "@ctfdio/ctfd-js/utils/html"; +import { getScript } from "@ctfdio/ctfd-js/utils/ajax"; + +const api = new API("/"); +const user = {}; +const _internal = {}; +const ui = { + ezq, +}; +const lib = { + $, + markdown, + dayjs, +}; + +let initialized = false; +const init = (data) => { + if (initialized) { + return; + } + initialized = true; + + config.urlRoot = data.urlRoot || config.urlRoot; + config.csrfNonce = data.csrfNonce || config.csrfNonce; + config.userMode = data.userMode || config.userMode; + api.domain = config.urlRoot + "/api/v1"; + user.id = data.userId; +}; +const plugin = { + run: (f) => { + f(CTFd); + }, +}; +function markdown(config) { + // Merge passed config with original. Default to original. + const md_config = { ...{ html: true, linkify: true }, ...config }; + const md = MarkdownIt(md_config); + md.renderer.rules.link_open = function (tokens, idx, options, env, self) { + tokens[idx].attrPush(["target", "_blank"]); + return self.renderToken(tokens, idx, options); + }; + return md; +} + +const utils = { + ajax: { + getScript, + }, + html: { + createHtmlNode, + htmlEntities, + }, +}; + +const CTFd = { + init, + config, + fetch, + user, + ui, + utils, + api, + lib, + _internal, + plugin, +}; + +export default CTFd; diff --git a/CTFd/themes/admin/assets/js/compat/api.js b/CTFd/themes/admin/assets/js/compat/api.js new file mode 100644 index 0000000000..1b8de6f73d --- /dev/null +++ b/CTFd/themes/admin/assets/js/compat/api.js @@ -0,0 +1,3619 @@ +import fetch from "./fetch"; +/*jshint esversion: 6 */ +// eslint-disable-next-line no-redeclare, no-unused-vars +/*global fetch, btoa */ +import Q from "q"; +/** + * + * @class API + * @param {(string|object)} [domainOrOptions] - The project domain or options object. If object, see the object's optional properties. + * @param {string} [domainOrOptions.domain] - The project domain + * @param {object} [domainOrOptions.token] - auth token - object with value property and optional headerOrQueryName and isQuery properties + */ +let API = (function () { + "use strict"; + + function API(options) { + let domain = typeof options === "object" ? options.domain : options; + this.domain = domain ? domain : ""; + if (this.domain.length === 0) { + throw new Error("Domain parameter must be specified as a string."); + } + } + + function serializeQueryParams(parameters) { + let str = []; + for (let p in parameters) { + // eslint-disable-next-line no-prototype-builtins + if (parameters.hasOwnProperty(p)) { + str.push( + encodeURIComponent(p) + "=" + encodeURIComponent(parameters[p]), + ); + } + } + return str.join("&"); + } + + function mergeQueryParams(parameters, queryParameters) { + if (parameters.$queryParameters) { + Object.keys(parameters.$queryParameters).forEach( + function (parameterName) { + let parameter = parameters.$queryParameters[parameterName]; + queryParameters[parameterName] = parameter; + }, + ); + } + return queryParameters; + } + + /** + * HTTP Request + * @method + * @name API#request + * @param {string} method - http method + * @param {string} url - url to do request + * @param {object} parameters + * @param {object} body - body parameters / object + * @param {object} headers - header parameters + * @param {object} queryParameters - querystring parameters + * @param {object} form - form data object + * @param {object} deferred - promise object + */ + API.prototype.request = function ( + method, + url, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ) { + const queryParams = + queryParameters && Object.keys(queryParameters).length + ? serializeQueryParams(queryParameters) + : null; + const urlWithParams = url + (queryParams ? "?" + queryParams : ""); + + if (body && !Object.keys(body).length) { + body = undefined; + } + + fetch(urlWithParams, { + method, + headers, + body: JSON.stringify(body), + }) + .then((response) => { + return response.json(); + }) + .then((body) => { + deferred.resolve(body); + }) + .catch((error) => { + deferred.reject(error); + }); + }; + + /** + * + * @method + * @name API#post_award_list + * @param {object} parameters - method options and parameters + */ + API.prototype.post_award_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/awards"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "POST", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#delete_award + * @param {object} parameters - method options and parameters + * @param {string} parameters.awardId - An Award ID + */ + API.prototype.delete_award = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/awards/{award_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{award_id}", parameters["awardId"]); + + if (parameters["awardId"] === undefined) { + deferred.reject(new Error("Missing required parameter: awardId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "DELETE", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_award + * @param {object} parameters - method options and parameters + * @param {string} parameters.awardId - An Award ID + */ + API.prototype.get_award = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/awards/{award_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{award_id}", parameters["awardId"]); + + if (parameters["awardId"] === undefined) { + deferred.reject(new Error("Missing required parameter: awardId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#post_challenge_list + * @param {object} parameters - method options and parameters + */ + API.prototype.post_challenge_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/challenges"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "POST", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_challenge_list + * @param {object} parameters - method options and parameters + */ + API.prototype.get_challenge_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/challenges"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#post_challenge_attempt + * @param {object} parameters - method options and parameters + */ + API.prototype.post_challenge_attempt = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/challenges/attempt"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "POST", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_challenge_types + * @param {object} parameters - method options and parameters + */ + API.prototype.get_challenge_types = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/challenges/types"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#patch_challenge + * @param {object} parameters - method options and parameters + * @param {string} parameters.challengeId - A Challenge ID + */ + API.prototype.patch_challenge = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/challenges/{challenge_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{challenge_id}", parameters["challengeId"]); + + if (parameters["challengeId"] === undefined) { + deferred.reject(new Error("Missing required parameter: challengeId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "PATCH", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#delete_challenge + * @param {object} parameters - method options and parameters + * @param {string} parameters.challengeId - A Challenge ID + */ + API.prototype.delete_challenge = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/challenges/{challenge_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{challenge_id}", parameters["challengeId"]); + + if (parameters["challengeId"] === undefined) { + deferred.reject(new Error("Missing required parameter: challengeId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "DELETE", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_challenge + * @param {object} parameters - method options and parameters + * @param {string} parameters.challengeId - A Challenge ID + */ + API.prototype.get_challenge = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/challenges/{challenge_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{challenge_id}", parameters["challengeId"]); + + if (parameters["challengeId"] === undefined) { + deferred.reject(new Error("Missing required parameter: challengeId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_challenge_files + * @param {object} parameters - method options and parameters + * @param {string} parameters.id - A Challenge ID + * @param {string} parameters.challengeId - + */ + API.prototype.get_challenge_files = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/challenges/{challenge_id}/files"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + if (parameters["id"] !== undefined) { + queryParameters["id"] = parameters["id"]; + } + + path = path.replace("{challenge_id}", parameters["challengeId"]); + + if (parameters["challengeId"] === undefined) { + deferred.reject(new Error("Missing required parameter: challengeId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_challenge_flags + * @param {object} parameters - method options and parameters + * @param {string} parameters.id - A Challenge ID + * @param {string} parameters.challengeId - + */ + API.prototype.get_challenge_flags = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/challenges/{challenge_id}/flags"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + if (parameters["id"] !== undefined) { + queryParameters["id"] = parameters["id"]; + } + + path = path.replace("{challenge_id}", parameters["challengeId"]); + + if (parameters["challengeId"] === undefined) { + deferred.reject(new Error("Missing required parameter: challengeId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_challenge_hints + * @param {object} parameters - method options and parameters + * @param {string} parameters.id - A Challenge ID + * @param {string} parameters.challengeId - + */ + API.prototype.get_challenge_hints = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/challenges/{challenge_id}/hints"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + if (parameters["id"] !== undefined) { + queryParameters["id"] = parameters["id"]; + } + + path = path.replace("{challenge_id}", parameters["challengeId"]); + + if (parameters["challengeId"] === undefined) { + deferred.reject(new Error("Missing required parameter: challengeId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_challenge_solves + * @param {object} parameters - method options and parameters + * @param {string} parameters.id - A Challenge ID + * @param {string} parameters.challengeId - + */ + API.prototype.get_challenge_solves = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/challenges/{challenge_id}/solves"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + if (parameters["id"] !== undefined) { + queryParameters["id"] = parameters["id"]; + } + + path = path.replace("{challenge_id}", parameters["challengeId"]); + + if (parameters["challengeId"] === undefined) { + deferred.reject(new Error("Missing required parameter: challengeId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_challenge_tags + * @param {object} parameters - method options and parameters + * @param {string} parameters.id - A Challenge ID + * @param {string} parameters.challengeId - + */ + API.prototype.get_challenge_tags = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/challenges/{challenge_id}/tags"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + if (parameters["id"] !== undefined) { + queryParameters["id"] = parameters["id"]; + } + + path = path.replace("{challenge_id}", parameters["challengeId"]); + + if (parameters["challengeId"] === undefined) { + deferred.reject(new Error("Missing required parameter: challengeId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#post_config_list + * @param {object} parameters - method options and parameters + */ + API.prototype.post_config_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/configs"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "POST", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#patch_config_list + * @param {object} parameters - method options and parameters + */ + API.prototype.patch_config_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/configs"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "PATCH", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_config_list + * @param {object} parameters - method options and parameters + */ + API.prototype.get_config_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/configs"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#patch_config + * @param {object} parameters - method options and parameters + * @param {string} parameters.configKey - + */ + API.prototype.patch_config = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/configs/{config_key}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{config_key}", parameters["configKey"]); + + if (parameters["configKey"] === undefined) { + deferred.reject(new Error("Missing required parameter: configKey")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "PATCH", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#delete_config + * @param {object} parameters - method options and parameters + * @param {string} parameters.configKey - + */ + API.prototype.delete_config = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/configs/{config_key}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{config_key}", parameters["configKey"]); + + if (parameters["configKey"] === undefined) { + deferred.reject(new Error("Missing required parameter: configKey")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "DELETE", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_config + * @param {object} parameters - method options and parameters + * @param {string} parameters.configKey - + */ + API.prototype.get_config = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/configs/{config_key}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{config_key}", parameters["configKey"]); + + if (parameters["configKey"] === undefined) { + deferred.reject(new Error("Missing required parameter: configKey")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#post_files_list + * @param {object} parameters - method options and parameters + */ + API.prototype.post_files_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/files"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "POST", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_files_list + * @param {object} parameters - method options and parameters + */ + API.prototype.get_files_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/files"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#delete_files_detail + * @param {object} parameters - method options and parameters + * @param {string} parameters.fileId - + */ + API.prototype.delete_files_detail = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/files/{file_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{file_id}", parameters["fileId"]); + + if (parameters["fileId"] === undefined) { + deferred.reject(new Error("Missing required parameter: fileId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "DELETE", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_files_detail + * @param {object} parameters - method options and parameters + * @param {string} parameters.fileId - + */ + API.prototype.get_files_detail = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/files/{file_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{file_id}", parameters["fileId"]); + + if (parameters["fileId"] === undefined) { + deferred.reject(new Error("Missing required parameter: fileId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#post_flag_list + * @param {object} parameters - method options and parameters + */ + API.prototype.post_flag_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/flags"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "POST", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_flag_list + * @param {object} parameters - method options and parameters + */ + API.prototype.get_flag_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/flags"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_flag_types + * @param {object} parameters - method options and parameters + */ + API.prototype.get_flag_types = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/flags/types"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_flag_types_1 + * @param {object} parameters - method options and parameters + * @param {string} parameters.typeName - + */ + API.prototype.get_flag_types_1 = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/flags/types/{type_name}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{type_name}", parameters["typeName"]); + + if (parameters["typeName"] === undefined) { + deferred.reject(new Error("Missing required parameter: typeName")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#patch_flag + * @param {object} parameters - method options and parameters + * @param {string} parameters.flagId - + */ + API.prototype.patch_flag = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/flags/{flag_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{flag_id}", parameters["flagId"]); + + if (parameters["flagId"] === undefined) { + deferred.reject(new Error("Missing required parameter: flagId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "PATCH", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#delete_flag + * @param {object} parameters - method options and parameters + * @param {string} parameters.flagId - + */ + API.prototype.delete_flag = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/flags/{flag_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{flag_id}", parameters["flagId"]); + + if (parameters["flagId"] === undefined) { + deferred.reject(new Error("Missing required parameter: flagId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "DELETE", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_flag + * @param {object} parameters - method options and parameters + * @param {string} parameters.flagId - + */ + API.prototype.get_flag = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/flags/{flag_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{flag_id}", parameters["flagId"]); + + if (parameters["flagId"] === undefined) { + deferred.reject(new Error("Missing required parameter: flagId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#post_hint_list + * @param {object} parameters - method options and parameters + */ + API.prototype.post_hint_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/hints"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "POST", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_hint_list + * @param {object} parameters - method options and parameters + */ + API.prototype.get_hint_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/hints"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#patch_hint + * @param {object} parameters - method options and parameters + * @param {string} parameters.hintId - + */ + API.prototype.patch_hint = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/hints/{hint_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{hint_id}", parameters["hintId"]); + + if (parameters["hintId"] === undefined) { + deferred.reject(new Error("Missing required parameter: hintId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "PATCH", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#delete_hint + * @param {object} parameters - method options and parameters + * @param {string} parameters.hintId - + */ + API.prototype.delete_hint = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/hints/{hint_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{hint_id}", parameters["hintId"]); + + if (parameters["hintId"] === undefined) { + deferred.reject(new Error("Missing required parameter: hintId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "DELETE", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_hint + * @param {object} parameters - method options and parameters + * @param {string} parameters.hintId - + */ + API.prototype.get_hint = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/hints/{hint_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{hint_id}", parameters["hintId"]); + + if (parameters["hintId"] === undefined) { + deferred.reject(new Error("Missing required parameter: hintId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#post_notification_list + * @param {object} parameters - method options and parameters + */ + API.prototype.post_notification_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/notifications"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "POST", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_notification_list + * @param {object} parameters - method options and parameters + */ + API.prototype.get_notification_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/notifications"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#delete_notification + * @param {object} parameters - method options and parameters + * @param {string} parameters.notificationId - A Notification ID + */ + API.prototype.delete_notification = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/notifications/{notification_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{notification_id}", parameters["notificationId"]); + + if (parameters["notificationId"] === undefined) { + deferred.reject(new Error("Missing required parameter: notificationId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "DELETE", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_notification + * @param {object} parameters - method options and parameters + * @param {string} parameters.notificationId - A Notification ID + */ + API.prototype.get_notification = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/notifications/{notification_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{notification_id}", parameters["notificationId"]); + + if (parameters["notificationId"] === undefined) { + deferred.reject(new Error("Missing required parameter: notificationId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#post_page_list + * @param {object} parameters - method options and parameters + */ + API.prototype.post_page_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/pages"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "POST", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_page_list + * @param {object} parameters - method options and parameters + */ + API.prototype.get_page_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/pages"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#patch_page_detail + * @param {object} parameters - method options and parameters + * @param {string} parameters.pageId - + */ + API.prototype.patch_page_detail = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/pages/{page_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{page_id}", parameters["pageId"]); + + if (parameters["pageId"] === undefined) { + deferred.reject(new Error("Missing required parameter: pageId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "PATCH", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#delete_page_detail + * @param {object} parameters - method options and parameters + * @param {string} parameters.pageId - + */ + API.prototype.delete_page_detail = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/pages/{page_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{page_id}", parameters["pageId"]); + + if (parameters["pageId"] === undefined) { + deferred.reject(new Error("Missing required parameter: pageId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "DELETE", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_page_detail + * @param {object} parameters - method options and parameters + * @param {string} parameters.pageId - + */ + API.prototype.get_page_detail = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/pages/{page_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{page_id}", parameters["pageId"]); + + if (parameters["pageId"] === undefined) { + deferred.reject(new Error("Missing required parameter: pageId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_scoreboard_list + * @param {object} parameters - method options and parameters + */ + API.prototype.get_scoreboard_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/scoreboard"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_scoreboard_detail + * @param {object} parameters - method options and parameters + * @param {string} parameters.count - How many top teams to return + */ + API.prototype.get_scoreboard_detail = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/scoreboard/top/{count}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{count}", parameters["count"]); + + if (parameters["count"] === undefined) { + deferred.reject(new Error("Missing required parameter: count")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_challenge_solve_statistics + * @param {object} parameters - method options and parameters + */ + API.prototype.get_challenge_solve_statistics = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/statistics/challenges/solves"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_challenge_solve_percentages + * @param {object} parameters - method options and parameters + */ + API.prototype.get_challenge_solve_percentages = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/statistics/challenges/solves/percentages"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_challenge_property_counts + * @param {object} parameters - method options and parameters + * @param {string} parameters.column - + */ + API.prototype.get_challenge_property_counts = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/statistics/challenges/{column}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{column}", parameters["column"]); + + if (parameters["column"] === undefined) { + deferred.reject(new Error("Missing required parameter: column")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_submission_property_counts + * @param {object} parameters - method options and parameters + * @param {string} parameters.column - + */ + API.prototype.get_submission_property_counts = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/statistics/submissions/{column}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{column}", parameters["column"]); + + if (parameters["column"] === undefined) { + deferred.reject(new Error("Missing required parameter: column")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_team_statistics + * @param {object} parameters - method options and parameters + */ + API.prototype.get_team_statistics = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/statistics/teams"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_user_statistics + * @param {object} parameters - method options and parameters + */ + API.prototype.get_user_statistics = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/statistics/users"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_user_property_counts + * @param {object} parameters - method options and parameters + * @param {string} parameters.column - + */ + API.prototype.get_user_property_counts = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/statistics/users/{column}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{column}", parameters["column"]); + + if (parameters["column"] === undefined) { + deferred.reject(new Error("Missing required parameter: column")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#post_submissions_list + * @param {object} parameters - method options and parameters + */ + API.prototype.post_submissions_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/submissions"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "POST", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_submissions_list + * @param {object} parameters - method options and parameters + */ + API.prototype.get_submissions_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/submissions"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#delete_submission + * @param {object} parameters - method options and parameters + * @param {string} parameters.submissionId - A Submission ID + */ + API.prototype.delete_submission = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/submissions/{submission_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{submission_id}", parameters["submissionId"]); + + if (parameters["submissionId"] === undefined) { + deferred.reject(new Error("Missing required parameter: submissionId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "DELETE", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_submission + * @param {object} parameters - method options and parameters + * @param {string} parameters.submissionId - A Submission ID + */ + API.prototype.get_submission = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/submissions/{submission_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{submission_id}", parameters["submissionId"]); + + if (parameters["submissionId"] === undefined) { + deferred.reject(new Error("Missing required parameter: submissionId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#post_tag_list + * @param {object} parameters - method options and parameters + */ + API.prototype.post_tag_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/tags"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "POST", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_tag_list + * @param {object} parameters - method options and parameters + */ + API.prototype.get_tag_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/tags"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#patch_tag + * @param {object} parameters - method options and parameters + * @param {string} parameters.tagId - A Tag ID + */ + API.prototype.patch_tag = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/tags/{tag_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{tag_id}", parameters["tagId"]); + + if (parameters["tagId"] === undefined) { + deferred.reject(new Error("Missing required parameter: tagId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "PATCH", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#delete_tag + * @param {object} parameters - method options and parameters + * @param {string} parameters.tagId - A Tag ID + */ + API.prototype.delete_tag = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/tags/{tag_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{tag_id}", parameters["tagId"]); + + if (parameters["tagId"] === undefined) { + deferred.reject(new Error("Missing required parameter: tagId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "DELETE", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_tag + * @param {object} parameters - method options and parameters + * @param {string} parameters.tagId - A Tag ID + */ + API.prototype.get_tag = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/tags/{tag_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{tag_id}", parameters["tagId"]); + + if (parameters["tagId"] === undefined) { + deferred.reject(new Error("Missing required parameter: tagId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#post_team_list + * @param {object} parameters - method options and parameters + */ + API.prototype.post_team_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/teams"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "POST", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_team_list + * @param {object} parameters - method options and parameters + */ + API.prototype.get_team_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/teams"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#patch_team_private + * @param {object} parameters - method options and parameters + * @param {string} parameters.teamId - Current Team + */ + API.prototype.patch_team_private = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/teams/me"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + if (parameters["teamId"] !== undefined) { + queryParameters["team_id"] = parameters["teamId"]; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "PATCH", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_team_private + * @param {object} parameters - method options and parameters + * @param {string} parameters.teamId - Current Team + */ + API.prototype.get_team_private = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/teams/me"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + if (parameters["teamId"] !== undefined) { + queryParameters["team_id"] = parameters["teamId"]; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#patch_team_public + * @param {object} parameters - method options and parameters + * @param {string} parameters.teamId - Team ID + */ + API.prototype.patch_team_public = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/teams/{team_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{team_id}", parameters["teamId"]); + + if (parameters["teamId"] === undefined) { + deferred.reject(new Error("Missing required parameter: teamId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "PATCH", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#delete_team_public + * @param {object} parameters - method options and parameters + * @param {string} parameters.teamId - Team ID + */ + API.prototype.delete_team_public = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/teams/{team_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{team_id}", parameters["teamId"]); + + if (parameters["teamId"] === undefined) { + deferred.reject(new Error("Missing required parameter: teamId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "DELETE", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_team_public + * @param {object} parameters - method options and parameters + * @param {string} parameters.teamId - Team ID + */ + API.prototype.get_team_public = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/teams/{team_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{team_id}", parameters["teamId"]); + + if (parameters["teamId"] === undefined) { + deferred.reject(new Error("Missing required parameter: teamId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_team_awards + * @param {object} parameters - method options and parameters + * @param {string} parameters.teamId - Team ID or 'me' + */ + API.prototype.get_team_awards = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/teams/{team_id}/awards"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{team_id}", parameters["teamId"]); + + if (parameters["teamId"] === undefined) { + deferred.reject(new Error("Missing required parameter: teamId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_team_fails + * @param {object} parameters - method options and parameters + * @param {string} parameters.teamId - Team ID or 'me' + */ + API.prototype.get_team_fails = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/teams/{team_id}/fails"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{team_id}", parameters["teamId"]); + + if (parameters["teamId"] === undefined) { + deferred.reject(new Error("Missing required parameter: teamId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_team_solves + * @param {object} parameters - method options and parameters + * @param {string} parameters.teamId - Team ID or 'me' + */ + API.prototype.get_team_solves = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/teams/{team_id}/solves"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{team_id}", parameters["teamId"]); + + if (parameters["teamId"] === undefined) { + deferred.reject(new Error("Missing required parameter: teamId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#post_unlock_list + * @param {object} parameters - method options and parameters + */ + API.prototype.post_unlock_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/unlocks"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "POST", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_unlock_list + * @param {object} parameters - method options and parameters + */ + API.prototype.get_unlock_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/unlocks"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#post_user_list + * @param {object} parameters - method options and parameters + */ + API.prototype.post_user_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/users"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "POST", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_user_list + * @param {object} parameters - method options and parameters + */ + API.prototype.get_user_list = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/users"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#patch_user_private + * @param {object} parameters - method options and parameters + */ + API.prototype.patch_user_private = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/users/me"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "PATCH", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_user_private + * @param {object} parameters - method options and parameters + */ + API.prototype.get_user_private = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/users/me"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#patch_user_public + * @param {object} parameters - method options and parameters + * @param {integer} parameters.userId - User ID + */ + API.prototype.patch_user_public = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/users/{user_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{user_id}", parameters["userId"]); + + if (parameters["userId"] === undefined) { + deferred.reject(new Error("Missing required parameter: userId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "PATCH", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#delete_user_public + * @param {object} parameters - method options and parameters + * @param {integer} parameters.userId - User ID + */ + API.prototype.delete_user_public = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/users/{user_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{user_id}", parameters["userId"]); + + if (parameters["userId"] === undefined) { + deferred.reject(new Error("Missing required parameter: userId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "DELETE", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_user_public + * @param {object} parameters - method options and parameters + * @param {integer} parameters.userId - User ID + */ + API.prototype.get_user_public = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/users/{user_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{user_id}", parameters["userId"]); + + if (parameters["userId"] === undefined) { + deferred.reject(new Error("Missing required parameter: userId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_user_awards + * @param {object} parameters - method options and parameters + * @param {string} parameters.userId - User ID or 'me' + */ + API.prototype.get_user_awards = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/users/{user_id}/awards"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{user_id}", parameters["userId"]); + + if (parameters["userId"] === undefined) { + deferred.reject(new Error("Missing required parameter: userId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_user_fails + * @param {object} parameters - method options and parameters + * @param {string} parameters.userId - User ID or 'me' + */ + API.prototype.get_user_fails = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/users/{user_id}/fails"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{user_id}", parameters["userId"]); + + if (parameters["userId"] === undefined) { + deferred.reject(new Error("Missing required parameter: userId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + /** + * + * @method + * @name API#get_user_solves + * @param {object} parameters - method options and parameters + * @param {string} parameters.userId - User ID or 'me' + */ + API.prototype.get_user_solves = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/users/{user_id}/solves"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{user_id}", parameters["userId"]); + + if (parameters["userId"] === undefined) { + deferred.reject(new Error("Missing required parameter: userId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; + }; + + return API; +})(); + +// eslint-disable-next-line no-undef +// exports.API = API; +export default API; diff --git a/CTFd/themes/admin/assets/js/compat/config.js b/CTFd/themes/admin/assets/js/compat/config.js new file mode 100644 index 0000000000..200c1fa69b --- /dev/null +++ b/CTFd/themes/admin/assets/js/compat/config.js @@ -0,0 +1,5 @@ +export default { + urlRoot: "", + csrfNonce: "", + userMode: "", +}; diff --git a/CTFd/themes/admin/assets/js/compat/events.js b/CTFd/themes/admin/assets/js/compat/events.js new file mode 100644 index 0000000000..a67844397a --- /dev/null +++ b/CTFd/themes/admin/assets/js/compat/events.js @@ -0,0 +1,122 @@ +import { Howl } from "howler"; +import { ezToast, ezAlert } from "./ezq"; +import { + WindowController, + init_notification_counter, + inc_notification_counter, + dec_notification_counter, +} from "./wc"; + +export default (root) => { + const source = new EventSource(root + "/events"); + const wc = new WindowController(); + const howl = new Howl({ + src: [ + root + "/themes/admin/static/sounds/notification.webm", + root + "/themes/admin/static/sounds/notification.mp3", + ], + }); + + init_notification_counter(); + + function connect() { + source.addEventListener( + "notification", + function (event) { + let data = JSON.parse(event.data); + wc.broadcast("notification", data); + + // Render in the master tab + render(data); + + // Only play sounds in the master tab + if (data.sound) { + howl.play(); + } + }, + false, + ); + } + + function disconnect() { + if (source) { + source.close(); + } + } + + function render(data) { + switch (data.type) { + case "toast": { + inc_notification_counter(); + // Trim toast body to length + let length = 50; + let trimmed_content = + data.content.length > length + ? data.content.substring(0, length - 3) + "..." + : data.content; + let clicked = false; + ezToast({ + title: data.title, + body: trimmed_content, + onclick: function () { + ezAlert({ + title: data.title, + body: data.html, + button: "Got it!", + success: function () { + clicked = true; + dec_notification_counter(); + }, + }); + }, + onclose: function () { + if (!clicked) { + dec_notification_counter(); + } + }, + }); + break; + } + case "alert": { + inc_notification_counter(); + ezAlert({ + title: data.title, + body: data.html, + button: "Got it!", + success: function () { + dec_notification_counter(); + }, + }); + break; + } + case "background": { + inc_notification_counter(); + break; + } + default: { + inc_notification_counter(); + break; + } + } + } + + wc.alert = function (data) { + render(data); + }; + + wc.toast = function (data) { + render(data); + }; + + wc.background = function (data) { + render(data); + }; + + wc.masterDidChange = function () { + if (this.isMaster) { + connect(); + } else { + disconnect(); + } + }; +}; diff --git a/CTFd/themes/admin/assets/js/compat/ezq.js b/CTFd/themes/admin/assets/js/compat/ezq.js new file mode 100644 index 0000000000..8315da0653 --- /dev/null +++ b/CTFd/themes/admin/assets/js/compat/ezq.js @@ -0,0 +1,221 @@ +import "bootstrap/js/dist/modal"; +import "bootstrap/js/dist/toast"; +import "../compat/format"; +import $ from "jquery"; +import hljs from "highlight.js"; + +const modalTpl = + '"; + +const toastTpl = + '"; + +const progressTpl = + '
' + + '
' + + "
" + + "
"; + +const errorTpl = + '"; + +const successTpl = + '"; + +const buttonTpl = + ''; +const noTpl = + ''; +const yesTpl = + ''; + +export function ezAlert(args) { + const modal = modalTpl.format(args.title); + const obj = $(modal); + + if (typeof args.body === "string") { + obj.find(".modal-body").append(`

${args.body}

`); + } else { + obj.find(".modal-body").append($(args.body)); + } + + const button = $(buttonTpl.format(args.button)); + + if (args.success) { + $(button).click(function () { + args.success(); + }); + } + + if (args.large) { + obj.find(".modal-dialog").addClass("modal-lg"); + } + + obj.find(".modal-footer").append(button); + + // Syntax highlighting + obj.find("pre code").each(function (_idx) { + hljs.highlightBlock(this); + }); + + $("main").append(obj); + + obj.modal("show"); + + $(obj).on("hidden.bs.modal", function () { + $(this).modal("dispose"); + }); + + return obj; +} + +export function ezToast(args) { + const container_available = $("#ezq--notifications-toast-container").length; + if (!container_available) { + $("body").append( + $("
").attr({ id: "ezq--notifications-toast-container" }).css({ + position: "fixed", + bottom: "0", + right: "0", + "min-width": "20%", + }), + ); + } + + var res = toastTpl.format(args.title, args.body); + var obj = $(res); + + if (args.onclose) { + $(obj) + .find("button[data-dismiss=toast]") + .click(function () { + args.onclose(); + }); + } + + if (args.onclick) { + let body = $(obj).find(".toast-body"); + body.addClass("cursor-pointer"); + body.click(function () { + args.onclick(); + }); + } + + let autohide = args.autohide !== false; + let animation = args.animation !== false; + let delay = args.delay || 10000; // 10 seconds + + $("#ezq--notifications-toast-container").prepend(obj); + + obj.toast({ + autohide: autohide, + delay: delay, + animation: animation, + }); + obj.toast("show"); + return obj; +} + +export function ezQuery(args) { + const modal = modalTpl.format(args.title); + const obj = $(modal); + + if (typeof args.body === "string") { + obj.find(".modal-body").append(`

${args.body}

`); + } else { + obj.find(".modal-body").append($(args.body)); + } + + const yes = $(yesTpl); + const no = $(noTpl); + + obj.find(".modal-footer").append(no); + obj.find(".modal-footer").append(yes); + + // Syntax highlighting + obj.find("pre code").each(function (_idx) { + hljs.highlightBlock(this); + }); + + $("main").append(obj); + + $(obj).on("hidden.bs.modal", function () { + $(this).modal("dispose"); + }); + + $(yes).click(function () { + args.success(); + }); + + obj.modal("show"); + + return obj; +} + +export function ezProgressBar(args) { + if (args.target) { + const obj = $(args.target); + const pbar = obj.find(".progress-bar"); + pbar.css("width", args.width + "%"); + return obj; + } + + const progress = progressTpl.format(args.width); + const modal = modalTpl.format(args.title); + + const obj = $(modal); + obj.find(".modal-body").append($(progress)); + $("main").append(obj); + + return obj.modal("show"); +} + +export function ezBadge(args) { + const mapping = { + success: successTpl, + error: errorTpl, + }; + + const tpl = mapping[args.type].format(args.body); + return $(tpl); +} + +const ezq = { + ezAlert: ezAlert, + ezToast: ezToast, + ezQuery: ezQuery, + ezProgressBar: ezProgressBar, + ezBadge: ezBadge, +}; +export default ezq; diff --git a/CTFd/themes/admin/assets/js/compat/fetch.js b/CTFd/themes/admin/assets/js/compat/fetch.js new file mode 100644 index 0000000000..0179e2b8a1 --- /dev/null +++ b/CTFd/themes/admin/assets/js/compat/fetch.js @@ -0,0 +1,25 @@ +import "whatwg-fetch"; +import config from "./config"; + +const fetch = window.fetch; + +export default (url, options) => { + if (options === undefined) { + options = { + method: "GET", + credentials: "same-origin", + headers: {}, + }; + } + url = config.urlRoot + url; + + if (options.headers === undefined) { + options.headers = {}; + } + options.credentials = "same-origin"; + options.headers["Accept"] = "application/json"; + options.headers["Content-Type"] = "application/json"; + options.headers["CSRF-Token"] = config.csrfNonce; + + return fetch(url, options); +}; diff --git a/CTFd/themes/admin/assets/js/compat/format.js b/CTFd/themes/admin/assets/js/compat/format.js new file mode 100644 index 0000000000..301e7d884b --- /dev/null +++ b/CTFd/themes/admin/assets/js/compat/format.js @@ -0,0 +1,10 @@ +//http://stackoverflow.com/a/2648463 - wizardry! +String.prototype.format = String.prototype.f = function () { + let s = this, + i = arguments.length; + + while (i--) { + s = s.replace(new RegExp("\\{" + i + "\\}", "gm"), arguments[i]); + } + return s; +}; diff --git a/CTFd/themes/admin/assets/js/compat/graphs.js b/CTFd/themes/admin/assets/js/compat/graphs.js new file mode 100644 index 0000000000..aedd6a8e15 --- /dev/null +++ b/CTFd/themes/admin/assets/js/compat/graphs.js @@ -0,0 +1,339 @@ +import $ from "jquery"; +import echarts from "echarts/dist/echarts.common"; +import dayjs from "dayjs"; +import { colorHash } from "./styles"; +import { cumulativeSum } from "./math"; + +const graph_configs = { + score_graph: { + format: (type, id, name, _account_id, responses) => { + let option = { + title: { + left: "center", + text: "Score over Time", + }, + tooltip: { + trigger: "axis", + axisPointer: { + type: "cross", + }, + }, + legend: { + type: "scroll", + orient: "horizontal", + align: "left", + bottom: 0, + data: [name], + }, + toolbox: { + feature: { + saveAsImage: {}, + }, + }, + grid: { + containLabel: true, + }, + xAxis: [ + { + type: "category", + boundaryGap: false, + data: [], + }, + ], + yAxis: [ + { + type: "value", + }, + ], + dataZoom: [ + { + id: "dataZoomX", + type: "slider", + xAxisIndex: [0], + filterMode: "filter", + height: 20, + top: 35, + fillerColor: "rgba(233, 236, 241, 0.4)", + }, + ], + series: [], + }; + + const times = []; + const scores = []; + const solves = responses[0].data; + const awards = responses[2].data; + const total = solves.concat(awards); + + total.sort((a, b) => { + return new Date(a.date) - new Date(b.date); + }); + + for (let i = 0; i < total.length; i++) { + const date = dayjs(total[i].date); + times.push(date.toDate()); + try { + scores.push(total[i].challenge.value); + } catch (e) { + scores.push(total[i].value); + } + } + + times.forEach((time) => { + option.xAxis[0].data.push(time); + }); + + option.series.push({ + name: window.stats_data.name, + type: "line", + label: { + normal: { + show: true, + position: "top", + }, + }, + areaStyle: { + normal: { + color: colorHash(name + id), + }, + }, + itemStyle: { + normal: { + color: colorHash(name + id), + }, + }, + data: cumulativeSum(scores), + }); + return option; + }, + }, + + category_breakdown: { + format: (type, id, name, account_id, responses) => { + let option = { + title: { + left: "center", + text: "Category Breakdown", + }, + tooltip: { + trigger: "item", + }, + toolbox: { + show: true, + feature: { + saveAsImage: {}, + }, + }, + legend: { + type: "scroll", + orient: "vertical", + top: "middle", + right: 0, + data: [], + }, + series: [ + { + name: "Category Breakdown", + type: "pie", + radius: ["30%", "50%"], + avoidLabelOverlap: false, + label: { + show: false, + position: "center", + }, + itemStyle: { + normal: { + label: { + show: true, + formatter: function (data) { + return `${data.percent}% (${data.value})`; + }, + }, + labelLine: { + show: true, + }, + }, + emphasis: { + label: { + show: true, + position: "center", + textStyle: { + fontSize: "14", + fontWeight: "normal", + }, + }, + }, + }, + emphasis: { + label: { + show: true, + fontSize: "30", + fontWeight: "bold", + }, + }, + labelLine: { + show: false, + }, + data: [], + }, + ], + }; + const solves = responses[0].data; + const categories = []; + + for (let i = 0; i < solves.length; i++) { + categories.push(solves[i].challenge.category); + } + + const keys = categories.filter((elem, pos) => { + return categories.indexOf(elem) == pos; + }); + + const counts = []; + for (let i = 0; i < keys.length; i++) { + let count = 0; + for (let x = 0; x < categories.length; x++) { + if (categories[x] == keys[i]) { + count++; + } + } + counts.push(count); + } + + keys.forEach((category, index) => { + option.legend.data.push(category); + option.series[0].data.push({ + value: counts[index], + name: category, + itemStyle: { color: colorHash(category) }, + }); + }); + + return option; + }, + }, + + solve_percentages: { + format: (type, id, name, account_id, responses) => { + const solves_count = responses[0].data.length; + const fails_count = responses[1].meta.count; + let option = { + title: { + left: "center", + text: "Solve Percentages", + }, + tooltip: { + trigger: "item", + }, + toolbox: { + show: true, + feature: { + saveAsImage: {}, + }, + }, + legend: { + orient: "vertical", + top: "middle", + right: 0, + data: ["Fails", "Solves"], + }, + series: [ + { + name: "Solve Percentages", + type: "pie", + radius: ["30%", "50%"], + avoidLabelOverlap: false, + label: { + show: false, + position: "center", + }, + itemStyle: { + normal: { + label: { + show: true, + formatter: function (data) { + return `${data.name} - ${data.value} (${data.percent}%)`; + }, + }, + labelLine: { + show: true, + }, + }, + emphasis: { + label: { + show: true, + position: "center", + textStyle: { + fontSize: "14", + fontWeight: "normal", + }, + }, + }, + }, + emphasis: { + label: { + show: true, + fontSize: "30", + fontWeight: "bold", + }, + }, + labelLine: { + show: false, + }, + data: [ + { + value: fails_count, + name: "Fails", + itemStyle: { color: "rgb(207, 38, 0)" }, + }, + { + value: solves_count, + name: "Solves", + itemStyle: { color: "rgb(0, 209, 64)" }, + }, + ], + }, + ], + }; + + return option; + }, + }, +}; + +export function createGraph( + graph_type, + target, + data, + type, + id, + name, + account_id, +) { + const cfg = graph_configs[graph_type]; + let chart = echarts.init(document.querySelector(target)); + chart.setOption(cfg.format(type, id, name, account_id, data)); + $(window).on("resize", function () { + if (chart != null && chart != undefined) { + chart.resize(); + } + }); +} + +export function updateGraph( + graph_type, + target, + data, + type, + id, + name, + account_id, +) { + const cfg = graph_configs[graph_type]; + let chart = echarts.init(document.querySelector(target)); + chart.setOption(cfg.format(type, id, name, account_id, data)); +} + +export function disposeGraph(target) { + echarts.dispose(document.querySelector(target)); +} diff --git a/CTFd/themes/admin/assets/js/compat/helpers.js b/CTFd/themes/admin/assets/js/compat/helpers.js new file mode 100644 index 0000000000..81fd50221c --- /dev/null +++ b/CTFd/themes/admin/assets/js/compat/helpers.js @@ -0,0 +1,129 @@ +import $ from "jquery"; +import jQuery from "jquery"; +import { default as ezq } from "./ezq"; +import { htmlEntities } from "@ctfdio/ctfd-js/utils/html"; +import { colorHash } from "./styles"; +import { copyToClipboard } from "./ui"; + +const utils = { + htmlEntities: htmlEntities, + colorHash: colorHash, + copyToClipboard: copyToClipboard, +}; + +const files = { + upload: (form, extra_data, cb) => { + const CTFd = window.CTFd; + if (form instanceof jQuery) { + form = form[0]; + } + var formData = new FormData(form); + formData.append("nonce", CTFd.config.csrfNonce); + for (let [key, value] of Object.entries(extra_data)) { + formData.append(key, value); + } + + var pg = ezq.ezProgressBar({ + width: 0, + title: "Upload Progress", + }); + $.ajax({ + url: CTFd.config.urlRoot + "/api/v1/files", + data: formData, + type: "POST", + cache: false, + contentType: false, + processData: false, + xhr: function () { + var xhr = $.ajaxSettings.xhr(); + xhr.upload.onprogress = function (e) { + if (e.lengthComputable) { + var width = (e.loaded / e.total) * 100; + pg = ezq.ezProgressBar({ + target: pg, + width: width, + }); + } + }; + return xhr; + }, + success: function (data) { + form.reset(); + pg = ezq.ezProgressBar({ + target: pg, + width: 100, + }); + setTimeout(function () { + pg.modal("hide"); + }, 500); + + if (cb) { + cb(data); + } + }, + }); + }, +}; + +const comments = { + get_comments: (extra_args) => { + const CTFd = window.CTFd; + return CTFd.fetch("/api/v1/comments?" + $.param(extra_args), { + method: "GET", + credentials: "same-origin", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + }).then(function (response) { + return response.json(); + }); + }, + add_comment: (comment, type, extra_args, cb) => { + const CTFd = window.CTFd; + let body = { + content: comment, + type: type, + ...extra_args, + }; + CTFd.fetch("/api/v1/comments", { + method: "POST", + credentials: "same-origin", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }) + .then(function (response) { + return response.json(); + }) + .then(function (response) { + if (cb) { + cb(response); + } + }); + }, + delete_comment: (comment_id) => { + const CTFd = window.CTFd; + return CTFd.fetch(`/api/v1/comments/${comment_id}`, { + method: "DELETE", + credentials: "same-origin", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + }).then(function (response) { + return response.json(); + }); + }, +}; + +const helpers = { + files, + comments, + utils, + ezq, +}; + +export default helpers; diff --git a/CTFd/themes/admin/assets/js/compat/json.js b/CTFd/themes/admin/assets/js/compat/json.js new file mode 100644 index 0000000000..fa60e5f847 --- /dev/null +++ b/CTFd/themes/admin/assets/js/compat/json.js @@ -0,0 +1,39 @@ +import $ from "jquery"; + +$.fn.serializeJSON = function (omit_nulls) { + let params = {}; + let form = $(this); + let values = form.serializeArray(); + + values = values.concat( + form + .find("input[type=checkbox]:checked") + .map(function () { + return { name: this.name, value: true }; + }) + .get(), + ); + values = values.concat( + form + .find("input[type=checkbox]:not(:checked)") + .map(function () { + return { name: this.name, value: false }; + }) + .get(), + ); + values.map((x) => { + if (omit_nulls) { + if (x.value !== null && x.value !== "") { + params[x.name] = x.value; + } else { + let input = form.find(`:input[name='${x.name}']`); + if (input.data("initial") !== input.val()) { + params[x.name] = x.value; + } + } + } else { + params[x.name] = x.value; + } + }); + return params; +}; diff --git a/CTFd/themes/core-beta/assets/js/utils/math.js b/CTFd/themes/admin/assets/js/compat/math.js similarity index 100% rename from CTFd/themes/core-beta/assets/js/utils/math.js rename to CTFd/themes/admin/assets/js/compat/math.js diff --git a/CTFd/themes/admin/assets/js/compat/patch.js b/CTFd/themes/admin/assets/js/compat/patch.js new file mode 100644 index 0000000000..885aad8571 --- /dev/null +++ b/CTFd/themes/admin/assets/js/compat/patch.js @@ -0,0 +1,388 @@ +import Q from "q"; +import API from "./api"; + +function mergeQueryParams(parameters, queryParameters) { + return { ...parameters, ...queryParameters }; +} + +function serializeQueryParams(parameters) { + let str = []; + for (let p in parameters) { + // eslint-disable-next-line no-prototype-builtins + if (parameters.hasOwnProperty(p)) { + str.push(encodeURIComponent(p) + "=" + encodeURIComponent(parameters[p])); + } + } + return str.join("&"); +} + +API.prototype.requestRaw = function ( + method, + url, + parameters, + body, + headers, + queryParameters, + form, + deferred, +) { + const queryParams = + queryParameters && Object.keys(queryParameters).length + ? serializeQueryParams(queryParameters) + : null; + const urlWithParams = url + (queryParams ? "?" + queryParams : ""); + + if (body && !Object.keys(body).length) { + body = undefined; + } + + fetch(urlWithParams, { + method, + headers, + body: body, + }) + .then((response) => { + return response.json(); + }) + .then((body) => { + deferred.resolve(body); + }) + .catch((error) => { + deferred.reject(error); + }); +}; + +API.prototype.patch_user_public = function (parameters, body) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/users/{user_id}"; + let queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{user_id}", parameters["userId"]); + + if (parameters["userId"] === undefined) { + deferred.reject(new Error("Missing required parameter: userId")); + return deferred.promise; + } + + this.request( + "PATCH", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; +}; + +API.prototype.patch_user_private = function (parameters, body) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/users/me"; + let headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + this.request( + "PATCH", + domain + path, + parameters, + body, + headers, + {}, + form, + deferred, + ); + + return deferred.promise; +}; +API.prototype.post_unlock_list = function (parameters, body) { + let deferred = Q.defer(); + let domain = this.domain, + path = "/unlocks"; + let headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + this.request( + "POST", + domain + path, + parameters, + body, + headers, + {}, + form, + deferred, + ); + + return deferred.promise; +}; + +API.prototype.post_notification_list = function (parameters, body) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/notifications"; + let queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + this.request( + "POST", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; +}; + +API.prototype.post_files_list = function (parameters, body) { + let deferred = Q.defer(); + let domain = this.domain, + path = "/files"; + let queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + this.requestRaw( + "POST", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; +}; + +API.prototype.patch_config = function (parameters, body) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/configs/{config_key}"; + let queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{config_key}", parameters["configKey"]); + + if (parameters["configKey"] === undefined) { + deferred.reject(new Error("Missing required parameter: configKey")); + return deferred.promise; + } + + this.request( + "PATCH", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; +}; + +API.prototype.patch_config_list = function (parameters, body) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/configs"; + let queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "PATCH", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; +}; +API.prototype.post_tag_list = function (parameters, body) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/tags"; + let queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "POST", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; +}; +API.prototype.patch_team_public = function (parameters, body) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/teams/{team_id}"; + let queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{team_id}", parameters["teamId"]); + + if (parameters["teamId"] === undefined) { + deferred.reject(new Error("Missing required parameter: teamId")); + return deferred.promise; + } + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "PATCH", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; +}; +API.prototype.post_challenge_attempt = function (parameters, body) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/challenges/attempt"; + let queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "POST", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; +}; +API.prototype.get_hint = function (parameters) { + if (parameters === undefined) { + parameters = {}; + } + let deferred = Q.defer(); + let domain = this.domain, + path = "/hints/{hint_id}"; + let body = {}, + queryParameters = {}, + headers = {}, + form = {}; + + headers["Accept"] = ["application/json"]; + headers["Content-Type"] = ["application/json"]; + + path = path.replace("{hint_id}", parameters["hintId"]); + + if (parameters["hintId"] === undefined) { + deferred.reject(new Error("Missing required parameter: hintId")); + return deferred.promise; + } + delete parameters["hintId"]; + + queryParameters = mergeQueryParams(parameters, queryParameters); + + this.request( + "GET", + domain + path, + parameters, + body, + headers, + queryParameters, + form, + deferred, + ); + + return deferred.promise; +}; diff --git a/CTFd/themes/admin/assets/js/compat/styles.js b/CTFd/themes/admin/assets/js/compat/styles.js new file mode 100644 index 0000000000..5d2f19d964 --- /dev/null +++ b/CTFd/themes/admin/assets/js/compat/styles.js @@ -0,0 +1,20 @@ +// https://gist.github.com/0x263b/2bdd90886c2036a1ad5bcf06d6e6fb37 +export function colorHash(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + hash = hash & hash; + } + // Range calculation + // diff = max - min; + // x = ((hash % diff) + diff) % diff; + // return x + min; + // Calculate HSL values + // Range from 0 to 360 + let h = ((hash % 360) + 360) % 360; + // Range from 75 to 100 + let s = (((hash % 25) + 25) % 25) + 75; + // Range from 40 to 60 + let l = (((hash % 20) + 20) % 20) + 40; + return `hsl(${h}, ${s}%, ${l}%)`; +} diff --git a/CTFd/themes/core/assets/js/times.js b/CTFd/themes/admin/assets/js/compat/times.js similarity index 100% rename from CTFd/themes/core/assets/js/times.js rename to CTFd/themes/admin/assets/js/compat/times.js diff --git a/CTFd/themes/admin/assets/js/compat/ui.js b/CTFd/themes/admin/assets/js/compat/ui.js new file mode 100644 index 0000000000..f81eb2991f --- /dev/null +++ b/CTFd/themes/admin/assets/js/compat/ui.js @@ -0,0 +1,20 @@ +import $ from "jquery"; + +export function copyToClipboard(event, selector) { + // Select element + $(selector).select(); + + // Copy to clipboard + document.execCommand("copy"); + + // Show tooltip to user + $(event.target).tooltip({ + title: "Copied!", + trigger: "manual", + }); + $(event.target).tooltip("show"); + + setTimeout(function () { + $(event.target).tooltip("hide"); + }, 1500); +} diff --git a/CTFd/themes/admin/assets/js/compat/wc.js b/CTFd/themes/admin/assets/js/compat/wc.js new file mode 100644 index 0000000000..d8b871a2f1 --- /dev/null +++ b/CTFd/themes/admin/assets/js/compat/wc.js @@ -0,0 +1,171 @@ +import $ from "jquery"; + +// https://gist.github.com/neilj/4146038 +// https://fastmail.blog/2012/11/26/inter-tab-communication-using-local-storage/ +export function WindowController() { + this.id = Math.random(); + this.isMaster = false; + this.others = {}; + + window.addEventListener("storage", this, false); + window.addEventListener("unload", this, false); + + this.broadcast("hello"); + + var that = this; + var check = function check() { + that.check(); + that._checkTimeout = setTimeout(check, 9000); + }; + var ping = function ping() { + that.sendPing(); + that._pingTimeout = setTimeout(ping, 17000); + }; + this._checkTimeout = setTimeout(check, 500); + this._pingTimeout = setTimeout(ping, 17000); +} + +WindowController.prototype.destroy = function () { + clearTimeout(this._pingTimeout); + clearTimeout(this._checkTimeout); + + window.removeEventListener("storage", this, false); + window.removeEventListener("unload", this, false); + + this.broadcast("bye"); +}; + +WindowController.prototype.handleEvent = function (event) { + if (event.type === "unload") { + this.destroy(); + } else if (event.key === "broadcast") { + try { + var data = JSON.parse(event.newValue); + if (data.id !== this.id) { + this[data.type](data); + } + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + } + } +}; + +WindowController.prototype.sendPing = function () { + this.broadcast("ping"); +}; + +WindowController.prototype.hello = function (event) { + this.ping(event); + if (event.id < this.id) { + this.check(); + } else { + this.sendPing(); + } +}; + +WindowController.prototype.ping = function (event) { + this.others[event.id] = +new Date(); +}; + +WindowController.prototype.bye = function (event) { + delete this.others[event.id]; + this.check(); +}; + +WindowController.prototype.check = function (_event) { + var now = +new Date(), + takeMaster = true, + id; + for (id in this.others) { + if (this.others[id] + 23000 < now) { + delete this.others[id]; + } else if (id < this.id) { + takeMaster = false; + } + } + if (this.isMaster !== takeMaster) { + this.isMaster = takeMaster; + this.masterDidChange(); + } +}; + +WindowController.prototype.masterDidChange = function () {}; + +WindowController.prototype.broadcast = function (type, data) { + var event = { + id: this.id, + type: type, + }; + for (var x in data) { + event[x] = data[x]; + } + try { + localStorage.setItem("broadcast", JSON.stringify(event)); + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + } +}; + +// https://gist.github.com/0x263b/2bdd90886c2036a1ad5bcf06d6e6fb37 +export function colorHash(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + hash = hash & hash; + } + // Range calculation + // diff = max - min; + // x = ((hash % diff) + diff) % diff; + // return x + min; + // Calculate HSL values + // Range from 0 to 360 + let h = ((hash % 360) + 360) % 360; + // Range from 75 to 100 + let s = (((hash % 25) + 25) % 25) + 75; + // Range from 40 to 60 + let l = (((hash % 20) + 20) % 20) + 40; + return `hsl(${h}, ${s}%, ${l}%)`; +} + +const storage = window.localStorage; +const counter_key = "unread_notifications"; + +export function init_notification_counter() { + let count = storage.getItem(counter_key); + if (count === null) { + storage.setItem(counter_key, 0); + } else { + if (count > 0) { + $(".badge-notification").text(count); + } + } +} + +export function set_notification_counter(count) { + storage.setItem(counter_key, count); +} + +export function inc_notification_counter() { + let count = storage.getItem(counter_key) || 0; + storage.setItem(counter_key, ++count); + $(".badge-notification").text(count); +} + +export function dec_notification_counter() { + let count = storage.getItem(counter_key) || 0; + if (count > 0) { + storage.setItem(counter_key, --count); + $(".badge-notification").text(count); + } + // Always clear if count is 0 + if (count == 0) { + clear_notification_counter(); + } +} + +export function clear_notification_counter() { + storage.setItem(counter_key, 0); + $(".badge-notification").empty(); +} diff --git a/CTFd/themes/admin/assets/js/components/comments/CommentBox.vue b/CTFd/themes/admin/assets/js/components/comments/CommentBox.vue index 7ee7c1f274..1f6ae3f5db 100644 --- a/CTFd/themes/admin/assets/js/components/comments/CommentBox.vue +++ b/CTFd/themes/admin/assets/js/components/comments/CommentBox.vue @@ -52,34 +52,33 @@
- +
-
+
+
-
-
- - +
+
+
+ {{ comment.author.name }} - - - - {{ toLocalTime(comment.date) }} - + + {{ toLocalTime(comment.date) }} +
@@ -118,8 +117,8 @@ diff --git a/CTFd/themes/admin/assets/js/components/configs/brackets/Bracket.vue b/CTFd/themes/admin/assets/js/components/configs/brackets/Bracket.vue new file mode 100644 index 0000000000..f2d196a67c --- /dev/null +++ b/CTFd/themes/admin/assets/js/components/configs/brackets/Bracket.vue @@ -0,0 +1,150 @@ + + + diff --git a/CTFd/themes/admin/assets/js/components/configs/brackets/BracketList.vue b/CTFd/themes/admin/assets/js/components/configs/brackets/BracketList.vue new file mode 100644 index 0000000000..f5e6a81c47 --- /dev/null +++ b/CTFd/themes/admin/assets/js/components/configs/brackets/BracketList.vue @@ -0,0 +1,72 @@ + + + diff --git a/CTFd/themes/admin/assets/js/components/configs/fields/Field.vue b/CTFd/themes/admin/assets/js/components/configs/fields/Field.vue index 484a2c20ff..d266050681 100644 --- a/CTFd/themes/admin/assets/js/components/configs/fields/Field.vue +++ b/CTFd/themes/admin/assets/js/components/configs/fields/Field.vue @@ -100,26 +100,26 @@ diff --git a/CTFd/themes/admin/assets/js/components/configs/fields/FieldList.vue b/CTFd/themes/admin/assets/js/components/configs/fields/FieldList.vue index c7bddb73c0..0557b4ee4a 100644 --- a/CTFd/themes/admin/assets/js/components/configs/fields/FieldList.vue +++ b/CTFd/themes/admin/assets/js/components/configs/fields/FieldList.vue @@ -25,40 +25,40 @@ diff --git a/CTFd/themes/admin/assets/js/components/files/ChallengeFilesList.vue b/CTFd/themes/admin/assets/js/components/files/ChallengeFilesList.vue index 1e0508d146..4a1f126f5a 100644 --- a/CTFd/themes/admin/assets/js/components/files/ChallengeFilesList.vue +++ b/CTFd/themes/admin/assets/js/components/files/ChallengeFilesList.vue @@ -9,10 +9,16 @@ - + {{ file.location.split("/").pop() }} +
+ SHA1: + + {{ file.sha1sum || "null" }} + +
@@ -56,69 +62,69 @@ diff --git a/CTFd/themes/admin/assets/js/components/files/MediaLibrary.vue b/CTFd/themes/admin/assets/js/components/files/MediaLibrary.vue index b95b9777cf..4d3b320856 100644 --- a/CTFd/themes/admin/assets/js/components/files/MediaLibrary.vue +++ b/CTFd/themes/admin/assets/js/components/files/MediaLibrary.vue @@ -32,15 +32,15 @@ - {{ + {{ file.location.split("/").pop() }} @@ -54,21 +54,23 @@
@@ -155,22 +157,40 @@
-
- - - - Attach multiple files using Control+Click or Cmd+Click. - +
+
+
+ + + + Attach multiple files using Control+Click or Cmd+Click. + +
+
+
+
+ + + + Route where file will be accessible (if not provided a + random folder will be used).
+ Provide as directory/filename.ext +
+
+
- +

@@ -57,23 +58,24 @@ diff --git a/CTFd/themes/admin/assets/js/components/flags/FlagEditForm.vue b/CTFd/themes/admin/assets/js/components/flags/FlagEditForm.vue index 87c36bf34c..2aca42e094 100644 --- a/CTFd/themes/admin/assets/js/components/flags/FlagEditForm.vue +++ b/CTFd/themes/admin/assets/js/components/flags/FlagEditForm.vue @@ -33,18 +33,19 @@ diff --git a/CTFd/themes/admin/assets/js/components/flags/FlagList.vue b/CTFd/themes/admin/assets/js/components/flags/FlagList.vue index 17c9038494..cf0e627e87 100644 --- a/CTFd/themes/admin/assets/js/components/flags/FlagList.vue +++ b/CTFd/themes/admin/assets/js/components/flags/FlagList.vue @@ -63,38 +63,38 @@ diff --git a/CTFd/themes/admin/assets/js/components/hints/HintCreationForm.vue b/CTFd/themes/admin/assets/js/components/hints/HintCreationForm.vue index 55f9319fbf..329e87e55b 100644 --- a/CTFd/themes/admin/assets/js/components/hints/HintCreationForm.vue +++ b/CTFd/themes/admin/assets/js/components/hints/HintCreationForm.vue @@ -24,6 +24,19 @@
+
+ + +
+
+ +