diff --git a/.codecov.yml b/.codecov.yml deleted file mode 100644 index 35dff8bb..00000000 --- a/.codecov.yml +++ /dev/null @@ -1,9 +0,0 @@ -coverage: - status: - project: - default: - # Fail the status if coverage drops by >= 1% - threshold: 1 - patch: - default: - threshold: 1 diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 7937a735..00000000 --- a/.dockerignore +++ /dev/null @@ -1,19 +0,0 @@ -CTFd/logs/*.log -CTFd/static/uploads -CTFd/uploads -CTFd/*.db -CTFd/uploads/**/* -.ctfd_secret_key -.data -.git -.codecov.yml -.dockerignore -.github -.gitignore -.prettierignore -.travis.yml -**/node_modules -**/*.pyc -**/__pycache__ -.venv* -venv* \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index d389b9f4..00000000 --- a/.eslintrc.js +++ /dev/null @@ -1,18 +0,0 @@ -module.exports = { - "env": { - "browser": true, - "es6": true - }, - "extends": "eslint:recommended", - "globals": { - "Atomics": "readonly", - "SharedArrayBuffer": "readonly" - }, - "parserOptions": { - "ecmaVersion": 2018, - "sourceType": "module" - }, - "rules": { - "no-unused-vars": ["error", { "argsIgnorePattern": "^_" }] - } -}; \ No newline at end of file diff --git a/.flaskenv b/.flaskenv deleted file mode 100644 index d41e74c0..00000000 --- a/.flaskenv +++ /dev/null @@ -1,2 +0,0 @@ -FLASK_ENV=development -FLASK_RUN_PORT=4000 diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index de174fc8..00000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,19 +0,0 @@ - - -**Environment**: - -- CTFd Version/Commit: -- Operating System: -- Web Browser and Version: - -**What happened?** - -**What did you expect to happen?** - -**How to reproduce your issue** - -**Any associated stack traces or error logs** diff --git a/CTFd/themes/core-beta/.github/workflows/build.yml b/.github/workflows/build.yml similarity index 100% rename from CTFd/themes/core-beta/.github/workflows/build.yml rename to .github/workflows/build.yml diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml deleted file mode 100644 index fdde0eab..00000000 --- a/.github/workflows/docker-build.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Docker build image on release - -on: - release: - types: [published] - -jobs: - docker: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - name: Set repo name lowercase - id: repo - uses: ASzc/change-string-case-action@v2 - with: - string: ${{ github.repository }} - - name: Checkout - uses: actions/checkout@v3 - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - name: Login to DockerHub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Login to GitHub Container Registry - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push - uses: docker/build-push-action@v3 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: | - ${{ steps.repo.outputs.lowercase }}:latest - ghcr.io/${{ steps.repo.outputs.lowercase }}:latest - ${{ steps.repo.outputs.lowercase }}:${{ github.event.release.tag_name }} - ghcr.io/${{ steps.repo.outputs.lowercase }}:${{ github.event.release.tag_name }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 33586f7b..00000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,44 +0,0 @@ ---- -name: Linting - -on: [push, pull_request] - -jobs: - build: - - runs-on: ubuntu-latest - - strategy: - matrix: - python-version: ['3.9'] - - name: Linting - steps: - - uses: actions/checkout@v2 - - name: Setup python - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - architecture: x64 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install -r development.txt - sudo yarn install --non-interactive - sudo yarn global add prettier@1.17.0 - - - name: Lint - run: make lint - env: - TESTING_DATABASE_URL: 'sqlite://' - - - name: Lint Dockerfile - uses: brpaz/hadolint-action@master - with: - dockerfile: "Dockerfile" - - - name: Lint docker-compose - run: | - python -m pip install docker-compose==1.26.0 - docker-compose -f docker-compose.yml config - diff --git a/.github/workflows/mysql.yml b/.github/workflows/mysql.yml deleted file mode 100644 index d167a163..00000000 --- a/.github/workflows/mysql.yml +++ /dev/null @@ -1,53 +0,0 @@ ---- -name: CTFd MySQL CI - -on: [push, pull_request] - -jobs: - build: - - runs-on: ubuntu-latest - services: - mysql: - image: mysql:5.7 - env: - MYSQL_ROOT_PASSWORD: password - ports: - - 3306 - options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 - redis: - image: redis - ports: - - 6379:6379 - - strategy: - matrix: - python-version: ['3.9'] - - 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@v1.0.11 - with: - file: ./coverage.xml diff --git a/.github/workflows/postgres.yml b/.github/workflows/postgres.yml deleted file mode 100644 index 3c228dad..00000000 --- a/.github/workflows/postgres.yml +++ /dev/null @@ -1,61 +0,0 @@ ---- -name: CTFd Postgres CI - -on: [push, pull_request] - -jobs: - build: - - runs-on: ubuntu-latest - services: - postgres: - image: postgres - ports: - - 5432:5432 - env: - POSTGRES_HOST_AUTH_METHOD: trust - POSTGRES_DB: ctfd - POSTGRES_PASSWORD: password - # Set health checks to wait until postgres has started - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - redis: - image: redis - ports: - - 6379:6379 - - strategy: - matrix: - python-version: ['3.9'] - - name: Python ${{ matrix.python-version }} - steps: - - uses: actions/checkout@v2 - - name: Setup python - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - architecture: x64 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install -r development.txt - sudo yarn install --non-interactive - - - name: Test - run: | - sudo rm -f /etc/boto.cfg - make test - env: - AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE - AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - TESTING_DATABASE_URL: postgres://postgres:password@localhost:${{ job.services.postgres.ports[5432] }}/ctfd - - - name: Codecov - uses: codecov/codecov-action@v1.0.11 - with: - file: ./coverage.xml - diff --git a/CTFd/themes/core-beta/.github/workflows/prettier.yml b/.github/workflows/prettier.yml similarity index 100% rename from CTFd/themes/core-beta/.github/workflows/prettier.yml rename to .github/workflows/prettier.yml diff --git a/.github/workflows/sqlite.yml b/.github/workflows/sqlite.yml deleted file mode 100644 index d00e683f..00000000 --- a/.github/workflows/sqlite.yml +++ /dev/null @@ -1,43 +0,0 @@ ---- -name: CTFd SQLite CI - -on: [push, pull_request] - -jobs: - build: - - runs-on: ubuntu-latest - - strategy: - matrix: - python-version: ['3.9'] - - name: Python ${{ matrix.python-version }} - steps: - - uses: actions/checkout@v2 - - name: Setup python - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - architecture: x64 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install -r development.txt - sudo yarn install --non-interactive - sudo yarn global add prettier@1.17.0 - - - name: Test - run: | - sudo rm -f /etc/boto.cfg - make test - env: - AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE - AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - TESTING_DATABASE_URL: 'sqlite://' - - - name: Codecov - uses: codecov/codecov-action@v1.0.11 - with: - file: ./coverage.xml - diff --git a/.gitignore b/.gitignore index cbe21c30..a547bf36 100644 --- a/.gitignore +++ b/.gitignore @@ -1,81 +1,24 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] - -# C extensions -*.so - -# Distribution / packaging -.Python -env/ -venv* -.venv* -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -*.egg-info/ -.installed.cfg -*.egg - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml - -# Translations -*.mo - -# Django stuff: +# Logs +logs *.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ +node_modules +dist +dist-ssr +*.local +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea .DS_Store - -*.db -*.log -*.log.* -.idea/ -.vscode/ -CTFd/static/uploads -CTFd/uploads -.data/ -.ctfd_secret_key -.*.swp - -# Vagrant -.vagrant - -# CTFd Exports -*.zip - -# JS -node_modules/ - -# Flask Profiler files -flask_profiler.sql +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/.isort.cfg b/.isort.cfg deleted file mode 100644 index 02d8a389..00000000 --- a/.isort.cfg +++ /dev/null @@ -1,7 +0,0 @@ -[settings] -multi_line_output=3 -include_trailing_comma=True -force_grid_wrap=0 -use_parentheses=True -line_length=88 -skip=migrations diff --git a/.prettierignore b/.prettierignore index 65782958..1e7e3a07 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,14 +1,2 @@ -CTFd/themes/**/vendor/ -CTFd/themes/core-beta/**/* -*.html -*.njk -*.png -*.svg -*.ico -*.ai -*.svg -*.mp3 -*.webm -.pytest_cache -venv* -.venv* +static +**/*.html \ No newline at end of file diff --git a/CTFd/themes/core-beta/.prettierrc.json b/.prettierrc.json similarity index 100% rename from CTFd/themes/core-beta/.prettierrc.json rename to .prettierrc.json diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index c552e029..00000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,1968 +0,0 @@ -# 3.5.2 / 2023-05-01 - -**General** - -- Generate cachable S3 URLs by rounding time down to the previous hour to generate a consistent URL -- Change email whitelist error message to not include the list of allowed domains -- Clean up the language for confirming the password on team password change -- Fix issue where dynamic challenges break if the decay is 0 and prevent users from adding a decay limit of 0 to dynamic value challenges - -**Admin Panel** - -- Adds support for admins to control `robots.txt` -- Clean up the aesthetics for the 'Pause CTF' and 'View After CTF' configs -- Replaced TLS and SSL checkbox text to match the defaults used by Mozilla Thunderbird to eliminate confusion when configuring SMTP - -**Deployment** - -- Slim down Docker image by removing several dependencies not needed for production usage - - The image size has been reduced from 648MB to 398MB -- In the Docker image run CTFd in a virtual environment located at `/opt/venv` -- Add freezegun to application dependencies -- Bump dependencies for pybluemonday, redis, SQLAlchemy-Utils, python-geoacumen-city -- Fix race conditions on cache healthcheck -- Fix situations where numeric config items in config.ini could cause CTFd to not start - -# 3.5.1 / 2023-01-23 - -**General** - -- The public scoreboard page is no longer shown to users if account visibility is disabled -- Teams created by admins using the normal team creation flow are now hidden by default -- Redirect users to the team creation page if they access a certain pages before the CTF starts -- Added a notice on the Challenges page to remind Admins if they are in Admins Only mode -- Fixed an issue where users couldn't login to their team even though they were already on the team -- Fixed an issue with scoreboard tie breaking when an award results in a tie -- Fixed the order of solves, fails, and awards to always be in chronological ordering (latest first). -- Fixed an issue where certain custom fields could not be submitted - -**Admin Panel** - -- Improved the rendering of Admin Panel tables on mobile devices -- Clarified the behavior of Score Visibility with respect to Account Visibility in the Admin Panel help text -- Added user id and user email fields to the user mode scoreboard CSV export -- Add CSV export for `teams+members+fields` which is teams with Custom Field entries and their team members with Custom Field entries -- The import process will now catch all exceptions in the import process to report them in the Admin Panel -- Fixed issue where `field_entries` could not be imported under MariaDB -- Fixed issue where `config` entries sometimes would be recreated for some reason causing an import to fail -- Fixed issue with Firefox caching checkboxes by adding `autocomplete='off'` to Admin Panel pages -- Fixed issue where Next selection for a challenge wouldn't always load in Admin Panel - -**API** - -- Improve response time of `/api/v1/challenges` and `/api/v1/challenges/[challenge_id]/solves` by caching the solve count data for users and challenges -- Add `HEAD /api/v1/notifications` to get a count of notifications that have happened. - - This also includes a `since_id` parameter to allow for a notification cursor. - - Unread notification count can now be tracked by themes that track which notifications a user has read -- Add `since_id` to `GET /api/v1/notifications` to get Notifications that have happened since a specific ID - -**Deployment** - -- Imports have been disabled when running with a SQLite database backend - - See https://github.com/CTFd/CTFd/issues/2131 -- Added `/healthcheck` endpoint to check if CTFd is ready -- There are now ARM Docker images for OSS CTFd -- Bump dependencies for passlib, bcrypt, requests, gunicorn, gevent, python-geoacumen-city, cmarkgfm -- Properly load `SAFE_MODE` config from environment variable -- The `AWS_S3_REGION` config has been added to allow specifying an S3 region. The default is `us-east-1` -- Add individual DATABASE config keys as an alternative to `DATABASE_URL` - - `DATABASE_PROTOCOL`: SQLAlchemy DB protocol (+ driver, optionally) - - `DATABASE_USER`: Username to access DB server with - - `DATABASE_PASSWORD`: Password to access DB server with - - `DATABASE_HOST`: Hostname of the DB server to access - - `DATABASE_PORT`: Port of the DB server to access - - `DATABASE_NAME`: Name of the database to use -- Add individual REDIS config keys as an alternative to `REDIS_URL` - - `REDIS_PROTOCOL`: Protocol to access Redis server with (either redis or rediss) - - `REDIS_USER`: Username to access Redis server with - - `REDIS_PASSWORD`: Password to access Redis server with - - `REDIS_HOST`: Hostname of the Redis server to access - - `REDIS_PORT`: Port of the Redis server to access - - `REDIS_DB`: Numeric ID of the database to access - -**Plugins** - -- Adds support for `config.json` to have multiple paths to add to the Plugins dropdown in the Admin Panel -- Plugins and their migrations now have access to the `get_all_tables` and `get_columns_for_table` functions -- Email sending functions have now been seperated into classes that can be customized via plugins. - - Add `CTFd.utils.email.providers.EmailProvider` - - Add `CTFd.utils.email.providers.mailgun.MailgunEmailProvider` - - Add `CTFd.utils.email.providers.smtp.SMTPEmailProvider` - - Deprecate `CTFd.utils.email.mailgun.sendmail` - - Deprecate `CTFd.utils.email.smtp.sendmail` - -**Themes** - -- The beta interface `Assets.manifest_css` has been removed -- `event-source-polyfill` is now pinned to 1.0.19. - - See https://github.com/CTFd/CTFd/issues/2159 - - Note that we will not be using this polyfill starting with the `core-beta` theme. -- Add autofocus to text fields on authentication pages - -# 3.5.0 / 2022-05-09 - -**General** - -- Add a next challenge recommendation to challenges -- Add support for only viewing hints after unlocking another hint -- Add size checking and recommendation for images uploaded during setup - -**Admin Panel** - -- Imports now happen in the background so that admins can watch the status of the import - - Add progress tracking to backup/export importing - - Add `GET /admin/import` to see status of import - - The public user facing portion of CTFd is now disabled during imports -- Fix issue where custom field entries for Users and Teams would be misaligned in the scoreboard CSV export -- Show admins the email server error message when email sending fails -- Fix issue where the current theme cannot be found in list of themes -- Fix page preview so that it accounts for the provided format -- Add links from User/Team Profile IP addresses to a User IP address search page -- Add city geolocation to Team Profile IP addresses - -**API** - -- Add the `count` meta field to the following endpoints: - - `/api/v1/users/me/solves` - - `/api/v1/users/me/fails` - - `/api/v1/users/me/awards` - - `/api/v1/teams/me/awards` - - `/api/v1/users/[user_id]/solves` - - `/api/v1/users/[user_id]/fails` - - `/api/v1/users/[user_id]/awards` - - `/api/v1/teams/[team_id]/solves` - - `/api/v1/teams/[team_id]/awards` -- Improve speed of `/api/v1/teams/me/fails` -- Improve speed of `/api/v1/teams/[team_id]/fails` -- Improve speed of `/api/v1/users/me/fails` -- Improve speed of `/api/v1/users/[user_id]/fails` - -**Deployment** - -- Use Python 3.9 as the default Python version -- Prevent any possible usage of an already existing session ID by checking for duplicates during during session ID generation -- No longer install `python3-dev` in Dockerfile -- docker-compose.yml now uses `nginx:stable` as the image for nginx - -**Plugins** - -- `CTFd._internal.challenge.render` and `CTFd._internal.challenge.renderer` in the `view.js` Challenge type file has been deprecated. Instead Challenge plugins should refer to the `challenge.html` attribute provided by the API. Essentially CTFd is moving to having markdown & HTML rendered by the server instead of rendering on the client. - -**Themes** - -- Create the [`core-beta` theme](https://github.com/CTFd/core-beta) and begin documenting the creation of themes using Vite -- Add `userName` and `userEmail` to the CTFd init object in `base.html` for easier integration with other JavaScript code -- Add `teamId` and `teamName` to the CTFd init object in `base.html` for easier integration with other JavaScript code -- Adds the `Assets` constant to access front end assets from Jinja templates -- Adds a `views.themes_beta` route to avoid the `.dev`/`.min` extension being added automatically to frontend asset urls - -**Miscellaneous** - -- Fix double logging in `log()` function -- Add `--delete_import_on_finish` to `python manage.py import_ctf` -- Fix issue where `field_entries` table could not be imported when moving between MySQL and MariaDB - -# 3.4.3 / 2022-03-07 - -**Security** - -- Bump cmarkgfm to 0.8.0 to resolve CVE-2022-24724. Copied entry from 3.4.2 since 3.4.2 introduced a bug that prevented writing raw HTML. - -**General** - -- Fix issue where raw HTML would not be rendered in markdown - -# 3.4.2 / 2022-03-07 - -**Security** - -- Bump cmarkgfm to 0.8.0 to resolve CVE-2022-24724 - -**General** - -- Fix issue where unauthed users couldn't download challenge files after CTF end but viewing after CTF was enabled - -# 3.4.1 / 2022-02-19 - -**General** - -- Make session cookies persist in the browser after close -- Fix issue where all-numeric registration codes wouldn't work -- Fix issue where a user's session isn't cleared properly after they are deleted by an admin -- Fix issue where CTF end time couldn't be set during setup - -**API** - -- Improved speed of the `/api/v1/challenges/[challenge_id]/solves` endpoint -- Document API authentication and `Content-Type` header requirement -- Add nested `UserSchema` and `TeamSchema` to `SubmissionSchema` for easier access to account name - -**Admin Panel** - -- Improve CSV import error reporting and validation -- Fix non-clickable checkbox label in user creation form in Admin Panel -- Allow submissions per minute ratelimit to be configurable in Admin Panel -- Add a link in the Pages Editor to the [Page Variables documentation page](https://docs.ctfd.io/docs/pages/variables/) - -**Themes** - -- Fix issue where invalid `theme_settings` can cause broken frontend -- Replace `node-sass` with `sass` and upgrade `sass-loader` - -**Deployment** - -- Serve all assets from CTFd regardless of internet availability (i.e. fonts and font-awesome) -- Fix regression in `REVERSE_PROXY` to allow comma seperated integers -- Bump `flask-restx` to 0.5.1 -- Bump `pybluemonday` to 0.0.9 -- Added support for S3 signature version 4 authentication to support alternative S3 buckets (Google Cloud Storage, DigitalOcean Spaces, etc) - -**Miscellaneous** - -- Add a Github Actions job to publish Docker images to Dockerhub and ghcr - -# 3.4.0 / 2021-08-11 - -**General** - -- Added the ability to have Challenge Topics - - Challenge Topics are small topic strings which are only visible to Admins - - They should denote what topics a given challenge involves -- Added `connection_info` to Challenges to allow Admins to more easily specify the connection info for a challenge -- Added ability to import CSVs of users, teams, and challenges -- Added ability to limit the total number of teams -- Pages now have access to variables `ctf_name`, `ctf_description`, `ctf_start`, `ctf_end`, `ctf_freeze`. (e.g. `{{ ctf_name }}`) -- IP Addresses in the Admin Panel will now show the city of the IP address as well as the country -- Make User Mode it's own dedicated tab in the setup flow and more clearly explain what each user mode does -- Added the ability to have a registration password - - Does not currently apply to SSO/auth provider or API based account creation -- Prevent users from participating with challenges if their profile is not complete (i.e. haven't filled out all required custom fields) -- Fixed an issue where admins couldn't see some challenges in the add requirements interface -- Fixed an issue where a challenge couldn't be accessed beacuse it had prerequisites on a deleted challenge -- Fixed an issue where User profiles could not be loaded in the Admin Panel due to missing/invalid Tracking IP addresses -- Fixed an issue where users with authentication provider accoutns would get an error when attempting to login -- Fixed an issue where MajorLeagueCyber config from config.ini was not being respected - -**API** - -- Added `connection_info` field to `/api/v1/challenges/[challenge_id]` -- Added `/api/v1/topics` for admins to create/delete topics -- Added `/api/v1/challenges/[challenge_id]/topics` for admins to list the topics on a challenge -- `/api/v1/challenges` will now sort by ID as value to better standardize API output with different databases -- `/api/v1/configs` will now provide an error message when provided Config values are too long -- `PATCH /api/v1/teams/[team_id]` will now only let team members be team captain - - No security issues here, it would just be invalid data. - -**Themes** - -- CTFd now has the `THEME_FALLBACK` option enabled by default. This allows users to provide incomplete themes. Missing theme files will be provided from the built-in core theme -- CTFd will now pass the title of a Page over to the template when rendering -- No longer show the token type in user settings -- Added `window.BETA_sortChallenges` to `/challenges` so that theme code can more easily define how to sort challenges - - Note that this functionality is beta because we expect to revamp the entire themes system -- Added `window.updateChallengeBoard` to `/challenges` so that theme code can more easily define when to update challenges - - Note that this functionality is beta because we expect to revamp the entire themes system -- Added `window.updateScoreboard` to `/scoreboard` so that theme code can more easily define when to update the scoreboard - - Note that this functionality is beta because we expect to revamp the entire themes system - -**Plugins** - -- Added `Challenges.plugin_class` to the Challenges model to access the challenge type plugin class from the Model - - Allows templates to access the plugin class more easily - - Allows plugins to access the plugin class without having to load the class explicitly - -**Admin Panel** - -- Reworked the Challenge Requirements UI - - Officially support the concept of anonymized challenges if prerequisites aren't met -- Added ability for Pages to be written in direct HTML instead of Markdown -- Pages now have access to variables `ctf_name`, `ctf_description`, `ctf_start`, `ctf_end`, `ctf_freeze` - - `ctf_start`, `ctf_end`, `ctf_freeze` are represented as ISO8601 timestamps -- Make it easier to change the user mode without having to delete all accounts. Instead we will only delete all submissions. -- When in team mode, user pages will now show their team's score instead of their own personal score -- Show a team member's individual score on their team's page -- Made the challenge creation form wider - -**Deployment** - -- The `THEME_FALLBACK` config is now set to true by default -- Replace installation and usage of `mysqladmin` (specifically `mysqladmin ping`) with a custom Python script -- Bump version of `pybluemonday` to 0.0.7 (fixes HTML sanitization bypasses and allows comments in HTML) -- Bump `pydantic` from 1.5.1 to 1.6.2 - -**Miscellaneous** - -- Make `.dockerignore` ignore `node_modules` in any subdirectory -- Added `solves` and `solved_by_me` fields to the Swagger documentation for Challenges -- Dynamic challenges will now take their initial valuation from the `inital` keyword instead of the previous `value` keyword. - - This allows ctfcli to manage dynamic challenges. See https://github.com/CTFd/CTFd/issues/1875 -- Added a timestamp to a CTFd export's filename -- Deleting uploads under the Filesystem upload provider will now delete the parent folder as well as the target file - -# 3.3.1 / 2021-07-15 - -**Security** - -- Fixes an issue where users could join teams without knowing the team password or having a team invite - -# 3.3.0 / 2021-03-26 - -**General** - -- Don't require a team for viewing challenges if Challenge visibility is set to public -- Add a `THEME_FALLBACK` config to help develop themes. See **Themes** section for details. - -**API** - -- Implement a faster `/api/v1/scoreboard` endpoint in Teams Mode -- Add the `solves` item to both `/api/v1/challenges` and `/api/v1/challenges/[challenge_id]` to more easily determine how many solves a challenge has -- Add the `solved_by_me` item to both `/api/v1/challenges` and `/api/v1/challenges/[challenge_id]` to more easily determine if the current account has solved the challenge -- Prevent admins from deleting themselves through `DELETE /api/v1/users/[user_id]` -- Add length checking to some sensitive fields in the Pages and Challenges schemas -- Fix issue where `PATCH /api/v1/users[user_id]` returned a list instead of a dict -- Fix exception that occured on demoting admins through `PATCH /api/v1/users[user_id]` -- Add `team_id` to `GET /api/v1/users` to determine if a user is already in a team -- Provide a more useful error message when using an expired token - -**Themes** - -- Add a `THEME_FALLBACK` config to help develop themes. - - `THEME_FALLBACK` will configure CTFd to try to find missing theme files in the default built-in `core` theme. - - This makes it easier to develop themes or use incomplete themes. -- Allow for one theme to reference and inherit from another theme through approaches like `{% extends "core/page.html" %}` -- Allow for the automatic date rendering format to be overridden by specifying a `data-time-format` attribute. -- Add styling for the `
` element. -- Change `users/private.html`, `users/public.html` to show awards before a user gets a solve -- Change `teams/private.html`, `teams/public.html` to show awards before a team gets a solve -- Change `colorHash` function to use HSL color values to avoid generating too light/dark colors -- Fix an issue where hidden users couldn't see their graphing data on their private user page (`/user`) -- Fix scoreboard table identifier to switch between User/Team depending on configured user mode -- Switch the challenges page in core to use the new API information in `/api/v1/challenges` to mark solves and display solve counts -- Switch to using Bootstrap's scss in `core/main.scss` to allow using Bootstrap variables -- Consolidate Jinja error handlers into a single function and better handle issues where error templates can't be found - -**Plugins** - -- Set plugin migration version after successful migrations -- Fix issue where Page URLs injected into the navbar were relative instead of absolute - -**Admin Panel** - -- Add User standings as well as Teams standings to the admin scoreboard when in Teams Mode -- Add a UI for adding members to a team from the team's admin page -- Add ability for admins to disable public team creation -- Link directly to users who submitted something in the submissions page if the CTF is in Teams Mode -- Fix Challenge Requirements interface in Admin Panel to not allow empty/null requirements to be added -- Fixed an issue where config times (start, end, freeze times) could not be removed -- Fix an exception that occurred when demoting an Admin user -- Adds a temporary hack for re-enabling Javascript snippets in Flag editor templates. (See #1779) - -**Deployment** - -- Fix boolean configs from the `config.ini` optional section -- Install `python3-dev` instead of `python-dev` in apt -- Require `pybluemonday` as pip dependency -- Remove `lxml` and `html5lib` from pip dependencies -- Bump `Jinja2` to 2.11.3 -- Bump `pip-tools` to 5.4.0 - -**Miscellaneous** - -- Rewrite the HTML santiziation feature (controlled by `HTML_SANITIZATION`) to use the `pybluemonday` library instead of `lxml`/`html5lib` - - Note that this feature is still in beta -- Cache Docker builds more by copying and installing Python dependencies before copying CTFd -- Change the default emails slightly and rework confirmation email page to make some recommendations clearer -- Use `examplectf.com` as testing/development domain instead of `ctfd.io` -- Fix issue where user's name and email would not appear in logs properly -- Add more linting by also linting with `flake8-comprehensions` and `flake8-bugbear` -- Add `.pyc` files and `__pycache__` to `.dockerignore` - -# 3.2.1 / 2020-12-09 - -- Fixed an issue where Users could not unlock Hints - -# 3.2.0 / 2020-12-07 - -**General** - -- Add Team invites. - - Team invites are links containing a token that allow a user to join a team without knowing the team password - - Captains can generate invite tokens for their teams - - Admins can generate Team invite links as well -- Improved Team handling - - Prevent team joining while already on a team - - Return 403 instead of 200 for team join/create errors - - Allow team captains whose teams haven't done anything to disband their team -- Allow for uploading navbar logo, favicon, and index page banner during initial setup -- Fixed issue in teams mode where a user couldn't unlock a hint despite their team having enough points - - The fix for this is essentially to allow the user's points to go negative -- Imports have been made more stable - - This is primarily done by killing MySQL processes that are locking metadta - - This is a subpar approach but it seems to be the only solution to avoid a metadata lock in MySQL. This approach did not appear to be needed under Postgres or SQLite - -**API** - -- Addition of `POST /api/v1/teams/me/members` to generate invite tokens for teams -- Fixed an issue in `POST /api/v1/awards` where CTFd would 500 when a user could not be found by the provided `user_id` -- `POST /api/v1/unlocks` in teams mode now uses the team's score to determine if a user can purchase a hint - - Properly check for existing unlocks in teams mode in `POST /api/v1/unlocks` -- `/api/v1/notifications` and `/api/v1/notifications/[notification_id]` now have an html parameter which specifies the rendered content of the notification content - -**Themes** - -- Add Team Invite icon and Disband Team icon to teams/private.html -- Add teams/invite.html file to handle team joining with invites -- Added syntax highlighting to challenge descriptions, pages, hints, notifications, comments, and markdown editors - - This is done with `highlight.js` which has been added to `package.json` -- Fix notifications to properly fix/support Markdown and HTML notifications - - Notifications SQL Model now has an html propery - - Notifications API schemas now has an html field -- Removed MomentJS (see https://momentjs.com/docs/#/-project-status/) in favor of dayjs - - dayjs is mostly API compatible with MomentJS. The only major changes were: - - dayjs always uses browser local time so you don't need to call `.local()` - - dayjs segments out some MomentJS functionality into plugins which need to be imported in before using those features -- Fixed issue in `challenge.html` where the current attempt count would have a typo -- Fixed issue in `challenge.html` where the max attempts for a challenge would not show if it was set to 1 -- Edit donut charts to have easier to read legends and labels -- Make data zoom bars thinner and more transparent -- Add logo, banner, and favicon settings to the setup.html - -**Plugins** - -- The `auth.register` (`/register`) endpoint now accepts a `?next=` parameter to define where to redirect to after registration -- There is now a `registered_only` decorator to redirect users to `auth.register` (`/register`) instead of `auth.login` (`/login`) -- Don't run `db.create_all()` as much during plugin upgrade or during imports - - By avoiding this we can let alembic and migrations do more of the table creation work but this means that plugins specifically opt into `app.db.create_all()` and will not implicitly get it through `upgrade()`. - - This means plugins that run `upgrade()` without a migrations folder (no idea who would do this really) will need to upgrade their code. -- The plugin `upgrade()` function now accepts a `lower` parameter which specifies what lower revision should be used to start from. - - This is used to support plugin migrations during import so that we can import data directly at the point that the import was taken from - - `lower="current"` means to use the current revision and `lower=None` would mean to use the absolute base revision (e.g. plugin's first installation) - - By default this doesn't change `upgrade()` behavior - -**Admin Panel** - -- Add Favicon uploading to the Admin Panel -- Move Logo uploading to the Theme tab in the Admin Panel -- The challenge left side bar tabs have been rewritten into VueJS components. - - This fixes a number of issues with the consistency of what data is deleted/edited in the challenge editor - - This also prevents having to refresh the page in most challenge editing situations -- Fixed a possible bug where the update available alert wouldn't go away on server restart -- Examples for regex flags are now provided -- Wrong submissions has been renamed to Incorrect Submissions -- Graphs in the Admin Statistics page will now scroll with mouse wheel to improve browsing large datasets -- Fixed an issue where Users/Teams could be created with a null password - -**Deployment** - -- A restart policy set to `always` has been added to nginx in docker-compose -- Rename `requirements.txt` to `requirements.in` and generate `requirements.txt` using `pip-tools` under Python 3.6 -- `UPLOAD_PROVIDER` no longer has a default `filesystem` set in config.ini. Instead it is defaulted through `config.py` - -**Miscellaneous** - -- The `psycopg2` dependency in development.txt has been removed in favor of `psycopg2-binary` which was updated to 2.8.6 -- The `moto` dependency in development.txt has been updated to 1.3.16 -- Add `pip-tools` to `development.txt` -- Add `import_ctf` and `export_ctf` commands to `manage.py` and deprecate `import.py` and `export.py` -- Override the `MAIL_SERVER` config with the `TESTING_MAIL_SERVER` envvar during tests -- `ping` events in the notification event handler have been fixed to not send duplicates - -# 3.1.1 / 2020-09-22 - -**General** - -- Fix notification deliverability when there are multiple tabs open -- Only play notification sounds in the master tab that receives the notification - -**Admin Panel** - -- Fix issue preventing admins from emailing users through the Admin Panel -- Improve Notification UI - - Clears notification form after notification submission - - Add notification to notification list after creation - -**Themes** - -- Add fix for trying to increment solves when solves are hidden -- Update JS dependencies to fix a transpiliation error preventing optional custom fields from being left empty - - `@babel/core`, `@babel/preset-env`, `@fortawesome/fontawesome-free`, `babel-loader`, and `@babel/polyfill` were updated but only `@babel/core` needs to be updated to resolve the transpiliation issue -- Remove `console.log` statements from minified production JS -- Compress notification sound and document the compression command - -**Miscellaneous** - -- Add the ability to override the sender header of email sent via SMTP with the `MAILSENDER_ADDR` config value - -# 3.1.0 / 2020-09-08 - -**General** - -- Loosen team password confirmation in team settings to also accept the team captain's password to make it easier to change the team password -- Adds the ability to add custom user and team fields for registration/profile settings. -- Improve Notifications pubsub events system to use a subscriber per server instead of a subscriber per browser. This should improve the reliability of CTFd at higher load and make it easier to deploy the Notifications system - -**Admin Panel** - -- Add a comments functionality for admins to discuss challenges, users, teams, pages -- Adds a legal section in Configs where users can add a terms of service and privacy policy -- Add a Custom Fields section in Configs where admins can add/edit custom user/team fields -- Move user graphs into a modal for Admin Panel - -**API** - -- Add `/api/v1/comments` to manipulate and create comments - -**Themes** - -- Make scoreboard caching only cache the score table instead of the entire page. This is done by caching the specific template section. Refer to #1586, specifically the changes in `scoreboard.html`. -- Add custom field inputs to profile pages (`teams/public.html`, `teams/private.html`, `users/public.html`, `users/private.html`), registration pages (`register.html`), and settings pages (`settings.html`). - - This is implemented in the core theme with `form.extra` & `user.fields` with a special helper (`render_extra_fields`) defined in `macros/forms.html`. The best way to implement this is to look at how the core theme handles it and copy the relevant behavior. -- Add rel=noopener to external links to prevent tab napping attacks -- Change the registration page to reference links to Terms of Service and Privacy Policy if specified in configuration - -**Miscellaneous** - -- Make team settings modal larger in the core theme -- Update tests in Github Actions to properly test under MySQL and Postgres -- Make gevent default in serve.py and add a `--disable-gevent` switch in serve.py -- Add `tenacity` library for retrying logic -- Add `pytest-sugar` for slightly prettier pytest output -- Add a `listen()` method to `CTFd.utils.events.EventManager` and `CTFd.utils.events.RedisEventManager`. - - This method should implement subscription for a CTFd worker to whatever underlying notification system there is. This should be implemented with gevent or a background thread. - - The `subscribe()` method (which used to implement the functionality of the new `listen()` function) now only handles passing notifications from CTFd to the browser. This should also be implemented with gevent or a background thread. - -# 3.0.2 / 2020-08-23 - -**Admin Panel** - -- Fix submission searching in Admin Panel -- Fix update banner being hidden behind navbar - -**Plugins** - -- Change default `input` & `submit` blocks in `challenge.html` to use the default values specified in the original challenge type plugins - -# 3.0.1 / 2020-08-12 - -**General** - -- Fix issue where admins could not see user graphs/api data if score visibility was set to hidden - -**Admin Panel** - -- Allow the Admin Panel Submissions page to filter by Account IDs, Challenge IDs, and Challenge Names -- Link to correct submissions for challenge from the challenge page - -**API** - -- Fix regression for creating hints via ctfcli. See #1582 for details. https://github.com/CTFd/CTFd/issues/1582. -- Deprecate `CTFd.api.v1.helpers.models.build_model_filters` and wrap it to `CTFd.utils.helpers.models.build_model_filters` - -**Themes** - -- Fix team pages to use the correct core errors component - -**Plugins** - -- Fix issues with previewing challenges under some challenge type plugins - -**Deployment** - -- Values specified in `config.ini` will now supercede values specified via environment variable. Config behavior is as follows: - 1. Config Key exists in `config.ini` and is set to a value. That value becomes the app config. - 2. Config Key exists in `config.ini` but is set to an empty string. An envvar with the same name is looked up. The envvar's value is used as the app config. - 3. If the envvar is not found, fall back to the default specified value in `config.py` - 4. If there is no default, use None or an empty string - -# 3.0.0 / 2020-07-27 - -## Changelog Summary - -The CTFd v3 Changelog represents the changes from v2.5.0 to v3. It is a summarized version of the changes that occured in all CTFd v3 beta/alpha releases. - -CTFd v3 contains some breaking changes but many plugins remain compatible. Themes will need some minor changes to be compatible with v3. - -These changes are made with great consideration to existing installations and for the health of the overall CTFd project. If you rely on specific behavior, you can always download the last CTFd v2 release on Github. Official plugin/theme updates will be sent to the email addresses on file. - -The major changes in CTFd v3 are as follows with the detailed changelog beneath: - -- ### Server Side HTML/Markdown Rendering - -HTML rendering in some cases (challenge description rendering, hint content rendering) has been moved to the server side. Previously it was rendered by the browser but this led to a lot of duplicated behavior and complexity in some plugins. Rendering that HTML content on the server allows CTFd to take more advantage of theme content and reduce duplicated code across themes. - -In addition, HTML sanitization can be enabled on the CTFd installation to prevent the injection of malicious scripts in HTML content. - -- ### CommonMark - -CTFd now uses [CommonMark](https://commonmark.org/) for HTML/Markdown rendering. This leads to much more consistent rendering of HTML/Markdown content. - -In some cases, this can break your HTML output. You can use our [development testing script](https://gist.github.com/ColdHeat/085c47359ab86c18864135a198cbe505) to check if your HTML output will change and correct it accordingly. - -- ### Forms, Nonces, Sessions - -CTFd no longer directly injects values into the global session object for a theme. You may have used this as `{{ nonce }}` or `{{ id }}`. Instead these values should be accessed via the `Session` global as so: `{{ Session.nonce }}`. - -All of the public facing forms in CTFd have been converted to form globals with WTForms. You can access them via the `Form` global in Jinja. For example, `{{ Forms.auth.LoginForm() }}`. A `{{ form.nonce() }}` function is available on all forms for easier access to the CSRF nonce as well. - -Old forms will still work if the nonce used in the form is updated to `{{ Session.nonce }}`. - -Values provided by configuration and plugins can now be accessed via the `Configs` and `Plugins` globals. For example `{{ Configs.ctf_name }}` and `{{ Plugins.scripts }}`. See the `base.html` file of the core theme to get an idea of how to use these values. - -- ### Challenge Type Plugin Enhancements - -Challenge type plugins now have better re-useability with the rest of CTFd. Plugin code no longer needs to copy unchanged methods over from the base challenge plugin classes. - -In addition, challenge HTML is now rendered on the server side using a new `challenge.html` file provided by the current theme. This means that the theme effectively controls how a challenge should look overall, but the challenge plugin controls the overall content. - -- ### Python 3 - -CTFd v3 is Python 3 only. - -- ### Docker image based on Debian - -The Docker image used in CTFd is now based on Debian. - -- ### config.ini - -Instead of editting `config.py` directly, it's now a better idea to edit `config.ini` or provide your configuration via environment variables - -## Detailed Changelog - -**General** - -- CTFd is now Python 3 only -- Render markdown with the CommonMark spec provided by `cmarkgfm` -- HTML/Markdown content is now rendered on the server side in most cases. - - This includes challenge descriptions, hint content, and page content -- Ability to render markdown stripped of any malicious JavaScript or HTML. - - Controlled via the `HTML_SANITIZATION` server side configuration value -- Inject `Config`, `User`, `Team`, `Session`, and `Plugin` globals into Jinja -- User sessions no longer store any user-specific attributes. - - Sessions only store the user's ID, CSRF nonce, and an hmac of the user's password - - This allows for session invalidation on password changes -- The user facing side of CTFd now has user and team searching -- Accept additional profile fields during registration (affiliation, website, country) - - This does not add additional inputs. Themes or additional JavaScript can add the form inputs. - -**Admin Panel** - -- Use EasyMDE as an improved description/text editor for Markdown enabled fields. -- Media Library button now integrated into EasyMDE enabled fields -- VueJS now used as the underlying implementation for the Media Library -- Fix setting theme color in Admin Panel -- Green outline border has been removed from the Admin Panel -- GeoIP support now available for converting IP addresses to guessed countries -- Redesign the challenge creation form to use a radio button with challenge type selection instead of a select input - -**API** - -- Significant overhauls in API documentation provided by Swagger UI and Swagger json -- Make almost all API endpoints provide filtering and searching capabilities -- Change `GET /api/v1/config/` to return structured data according to ConfigSchema -- Admins can no longer ban themselves through `PATCH /api/v1/users/[user_id]` -- Add `html` item for `GET /api/v1/hints/[hint_id]` which contains the rendered HTML of the Hint content -- Remove `content` from `GET /api/v1/hints` - -**Themes** - -- Themes now have access to the `Configs` global which provides wrapped access to `get_config`. - - For example, `{{ Configs.ctf_name }}` instead of `get_ctf_name()` or `get_config('ctf_name')` -- Themes must now specify a `challenge.html` which control how a challenge should look. -- The main library for charts has been changed from Plotly to Apache ECharts. -- Forms have been moved into wtforms for easier form rendering inside of Jinja. - - From Jinja you can access forms via the Forms global i.e. `{{ Forms }}` - - This allows theme developers to more easily re-use a form without having to copy-paste HTML. -- Themes can now provide a theme settings JSON blob which can be injected into the theme with `{{ Configs.theme_settings }}` -- Core theme now includes the challenge ID in location hash identifiers to always refer the right challenge despite duplicate names -- Spinner centering has been switched from a hard coded margin in CSS to flexbox CSS classes from Bootstrap - -**Plugins** - -- Challenge plugins have changed in structure to better allow integration with themes and prevent obtrusive Javascript/XSS. - - Challenge rendering now uses `challenge.html` from the provided theme. - - Accessing the challenge view content is now provided by `/api/v1/challenges/` in the `view` section. This allows for HTML to be properly sanitized and rendered by the server allowing CTFd to remove client side Jinja rendering. - - `challenge.html` now specifies what's required and what's rendered by the theme. This allows the challenge plugin to avoid having to deal with aspects of the challenge besides the description and input. - - A more complete migration guide will be provided when CTFd v3 leaves beta -- Display current attempt count in challenge view when max attempts is enabled -- `get_standings()`, `get_team_stanadings()`, `get_user_standings()` now has a fields keyword argument that allows for specificying additional fields that SQLAlchemy should return when building the response set. - - Useful for gathering additional data when building scoreboard pages -- Flags can now control the message that is shown to the user by raising `FlagException` -- Fix `override_template()` functionality - -**Deployment** - -- Enable SQLAlchemy's `pool_pre_ping` by default to reduce the likelihood of database connection issues -- Mailgun email settings are now deprecated. Admins should move to SMTP email settings instead. -- Postgres is now considered a second class citizen in CTFd. It is tested against but not a main database backend. If you use Postgres, you are entirely on your own with regards to supporting CTFd. -- Docker image now uses Debian instead of Alpine. See https://github.com/CTFd/CTFd/issues/1215 for rationale. -- `docker-compose.yml` now uses a non-root user to connect to MySQL/MariaDB -- `config.py` should no longer be editting for configuration, instead edit `config.ini` or the environment variables in `docker-compose.yml` - -**Miscellaneous** - -- Fix an issue where email sending would be broken if the CTF name contained a colon -- Lint Markdown files with Prettier -- Lint Dockerfile and docker-compose.yml in Github Actions -- Lint JavaScript files with eslint -- Certain static strings have been converted into Enums for better re-useability throughout the code base -- Switch to using Github Actions for testing and linting -- Better handling of missing challenge types. Missing challenge types no longer bring down all other challenges. -- Documentation has been seperated out into a seperate repo (https://github.com/CTFd/docs). -- Documentation hosting has moved from ReadTheDocs to Netlify -- Any links in the codebase to help.ctfd.io have been changed to docs.ctfd.io. - -# 3.0.0b3 / 2020-07-22 - -**General** - -- Render Hint content on the server side and provide it in the Hint API response - - In a sense this would deprecate the `content` field but it's left in for backwards compatability - -**API** - -- Add `html` item for `GET /api/v1/hints/[hint_id]` which contains the rendered HTML of the Hint content -- Remove `content` from `GET /api/v1/hints` - -**Admin Panel** - -- Fix an issue where an admin couldn't submit more than once on a challenge preview -- Fix an issue where the theme settings editor wouldn't load if the theme settings JSON was malformed - -**Miscellaneous** - -- Fix an issue where email sending would be broken if the CTF name contained a colon - -# 3.0.0b2 / 2020-07-19 - -**General** - -- Make HTML Sanitization an optional setting that's configurable via `HTML_SANITIZATION` in config.ini -- Allow HTML comments through sanitization -- Allow Bootstrap data attributes through sanitization - -**Admin Panel** - -- Fix an unclickable label in the Challenge creation interface - -**Plugins** - -- Fix bug preventing deleting alternative challenge types - -**Miscellaneous** - -- Switch to using Github Actions for testing and linting - -# 3.0.0b1 / 2020-07-15 - -**General** - -- Fix an issue where dynamic challenge solutions could not be submitted - -**Documentation** - -- Documentation has been seperated out into a seperate repo (https://github.com/CTFd/docs). -- Documentation hosting has moved from ReadTheDocs to Netlify -- Any links in the codebase to help.ctfd.io have been changed to docs.ctfd.io. - -# 3.0.0a2 / 2020-07-09 - -**General** - -- Accept additional profile fields during registration (affiliation, website, country) - - This does not add additional inputs. Themes or additional JavaScript can add the form inputs. - -**Admin Panel** - -- Redesign the challenge creation form to use a radio button with challenge type selection instead of a select input - -**API** - -- Admins can no longer ban themselves through `PATCH /api/v1/users/[user_id]` - -**Themes** - -- Spinner centering has been switched from a hard coded margin in CSS to flexbox CSS classes from Bootstrap - -**Plugins** - -- Revert plugin menu (`register_admin_plugin_menu_bar`, `register_user_page_menu_bar`) changes to 2.x code - -**Miscellaneous** - -- Fix issue with `Configs.ctf_name` returning incorrect value -- Add prerender step back into challenges.js -- Better handling of missing challenge types. Missing challenge types no longer bring down all other challenges. - -# 3.0.0a1 / 2020-07-01 - -**General** - -- CTFd is now Python 3 only -- Render markdown with the CommonMark spec provided by `cmarkgfm` -- Render markdown stripped of any malicious JavaScript or HTML. - - This is a significant change from previous versions of CTFd where any HTML content from an admin was considered safe. -- Inject `Config`, `User`, `Team`, `Session`, and `Plugin` globals into Jinja -- User sessions no longer store any user-specific attributes. - - Sessions only store the user's ID, CSRF nonce, and an hmac of the user's password - - This allows for session invalidation on password changes -- The user facing side of CTFd now has user and team searching -- GeoIP support now available for converting IP addresses to guessed countries - -**Admin Panel** - -- Use EasyMDE as an improved description/text editor for Markdown enabled fields. -- Media Library button now integrated into EasyMDE enabled fields -- VueJS now used as the underlying implementation for the Media Library -- Fix setting theme color in Admin Panel -- Green outline border has been removed from the Admin Panel - -**API** - -- Significant overhauls in API documentation provided by Swagger UI and Swagger json -- Make almost all API endpoints provide filtering and searching capabilities -- Change `GET /api/v1/config/` to return structured data according to ConfigSchema - -**Themes** - -- Themes now have access to the `Configs` global which provides wrapped access to `get_config`. - - For example, `{{ Configs.ctf_name }}` instead of `get_ctf_name()` or `get_config('ctf_name')` -- Themes must now specify a `challenge.html` which control how a challenge should look. -- The main library for charts has been changed from Plotly to Apache ECharts. -- Forms have been moved into wtforms for easier form rendering inside of Jinja. - - From Jinja you can access forms via the Forms global i.e. `{{ Forms }}` - - This allows theme developers to more easily re-use a form without having to copy-paste HTML. -- Themes can now provide a theme settings JSON blob which can be injected into the theme with `{{ Configs.theme_settings }}` -- Core theme now includes the challenge ID in location hash identifiers to always refer the right challenge despite duplicate names - -**Plugins** - -- Challenge plugins have changed in structure to better allow integration with themes and prevent obtrusive Javascript/XSS. - - Challenge rendering now uses `challenge.html` from the provided theme. - - Accessing the challenge view content is now provided by `/api/v1/challenges/` in the `view` section. This allows for HTML to be properly sanitized and rendered by the server allowing CTFd to remove client side Jinja rendering. - - `challenge.html` now specifies what's required and what's rendered by the theme. This allows the challenge plugin to avoid having to deal with aspects of the challenge besides the description and input. - - A more complete migration guide will be provided when CTFd v3 leaves beta -- Display current attempt count in challenge view when max attempts is enabled -- `get_standings()`, `get_team_stanadings()`, `get_user_standings()` now has a fields keyword argument that allows for specificying additional fields that SQLAlchemy should return when building the response set. - - Useful for gathering additional data when building scoreboard pages -- Flags can now control the message that is shown to the user by raising `FlagException` -- Fix `override_template()` functionality - -**Deployment** - -- Enable SQLAlchemy's `pool_pre_ping` by default to reduce the likelihood of database connection issues -- Mailgun email settings are now deprecated. Admins should move to SMTP email settings instead. -- Postgres is now considered a second class citizen in CTFd. It is tested against but not a main database backend. If you use Postgres, you are entirely on your own with regards to supporting CTFd. -- Docker image now uses Debian instead of Alpine. See https://github.com/CTFd/CTFd/issues/1215 for rationale. -- `docker-compose.yml` now uses a non-root user to connect to MySQL/MariaDB -- `config.py` should no longer be editting for configuration, instead edit `config.ini` or the environment variables in `docker-compose.yml` - -**Miscellaneous** - -- Lint Markdown files with Prettier -- Lint Dockerfile and docker-compose.yml in Github Actions -- Lint JavaScript files with eslint -- Certain static strings have been converted into Enums for better re-useability throughout the code base -- Main testing now done by Github Actions. Travis testing is deprecated but still used until full parity exists -- Travis testing has been upgraded to use Ubuntu Bionic (18.04) - -# 2.5.0 / 2020-06-04 - -**General** - -- Use a session invalidation strategy inspired by Django. Newly generated user sessions will now include a HMAC of the user's password. When the user's password is changed by someone other than the user the previous HMACs will no longer be valid and the user will be logged out when they next attempt to perform an action. -- A user and team's place, and score are now cached and invalidated on score changes. - -**API** - -- Add `/api/v1/challenges?view=admin` to allow admin users to see all challenges regardless of their visibility state -- Add `/api/v1/users?view=admin` to allow admin users to see all users regardless of their hidden/banned state -- Add `/api/v1/teams?view=admin` to allow admin users to see all teams regardless of their hidden/banned state -- The scoreboard endpoint `/api/v1/scoreboard` is now significantly more performant (20x) due to better response generation -- The top scoreboard endpoint `/api/v1/scoreboard/top/` is now more performant (3x) due to better response generation -- The scoreboard endpoint `/api/v1/scoreboard` will no longer show hidden/banned users in a non-hidden team - -**Deployment** - -- `docker-compose` now provides a basic nginx configuration and deploys nginx on port 80 -- `Dockerfile` now installs `python3` and `python3-dev` instead of `python` and `python-dev` because Alpine no longer provides those dependencies - -**Miscellaneous** - -- The `get_config` and `get_page` config utilities now use SQLAlchemy Core instead of SQLAlchemy ORM for slight speedups -- The `get_team_standings` and `get_user_standings` functions now return more data (id, oauth_id, name, score for regular users and banned, hidden as well for admins) -- Update Flask-Migrate to 2.5.3 and regenerate the migration environment. Fixes using `%` signs in database passwords. - -# 2.4.3 / 2020-05-24 - -**Miscellaneous** - -- Notifications/Events endpoint will now immediately send a ping instead of waiting a few seconds. -- Upgrade `gunicorn` dependency to `19.10.0` -- Upgrade `boto3` dependency to `1.13.9` -- Improve `import_ctf()` reliability by closing all connections before dropping & recreating database -- Close database session in IP tracking code in failure situations to avoid potential dangling database connections -- Don't allow backups to be imported if they do not have a `db` folder -- Change `import_ctf()` process slightly to import built-in tables first and then plugin tables -- Handle exception where a regex Flag is invalid - -**API** - -- File deletion endpoint (`DELETE /api/v1/files/[file_id]`) will now correctly delete the associated file - -**Plugins** - -- Add `CTFd.plugins.get_plugin_names()` to get a list of available plugins -- Add `CTFd.plugins.migrations.current()` to get the current revision of a plugin migration -- Improve `CTFd.plugins.migrations.upgrade()` to be able to upgrade to a specific plugin migration -- Run plugin migrations during import process - -**Themes** - -- Update jQuery to v3.5.1 to fix mobile hamburger menu -- Upgrade some dependencies in yarn lockfile -- Fix invalid team link being generated in `scoreboard.js` - -**Admin Panel** - -- Fix sending of user creation notification email -- Fix button to remove users from teams - -# 2.4.2 / 2020-05-08 - -**Admin Panel** - -- Fix Challenge Reset in Admin Panel where Dynamic Challenges prevented resetting Challenges - -**Plugins** - -- Add the `CTFd.plugins.migrations` module to allow plugins to handle migrations. Plugins should now call `CTFd.plugins.migrations.upgrade` instead of `app.db.create_all` which will allow the plugin to have database migrations. -- Make Dynamic Challenges have a cascading deletion constraint against their respective Challenge row - -**Miscellaneous** - -- Add `app.plugins_dir` object to refer to the directory where plugins are installed - -# 2.4.1 / 2020-05-06 - -**Admin Panel** - -- Fix issue where admins couldn't update the "Account Creation" email -- Fix issue where the Submissions page in the Admin Panel could not be paginated correctly - -**Miscellaneous** - -- Add `SQLALCHEMY_ENGINE_OPTIONS` to `config.py` with a slightly higher default `max_overflow` setting for `SQLALCHEMY_MAX_OVERFLOW`. This can be overridden with the `SQLALCHEMY_MAX_OVERFLOW` envvar -- Add `node_modules/` to `.dockerignore` - -# 2.4.0 / 2020-05-04 - -**General** - -- Cache user and team attributes and use those perform certain page operations intead of going to the database for data - - After modifying the user/team attributes you should call the appropriate cache clearing function (clear_user_session/clear_team_session) -- Cache user IPs for the last hour to avoid hitting the database on every authenticated page view - - Update the user IP's last seen value at least every hour or on every non-GET request -- Replace `flask_restplus` with `flask_restx` -- Remove `datafreeze`, `normality`, and `banal` dependencies in favor of in-repo solutions to exporting database - -**Admin Panel** - -- Add bulk selection and deletion for Users, Teams, Scoreboard, Challenges, Submissions -- Make some Admin tables sortable by table headers -- Create a score distribution graph in the statistics page -- Make instance reset more granular to allow for choosing to reset Accounts, Submissions, Challenges, Pages, and/or Notificatoins -- Properly update challenge visibility after updating challenge -- Show total possible points in Statistics page -- Add searching for Users, Teams, Challenges, Submissions -- Move User IP addresses into a modal -- Move Team IP addresses into a modal -- Show User website in a user page button -- Show Team website in a team page button -- Make the Pages editor use proper HTML syntax highlighting -- Theme header and footer editors now use CodeMirror -- Make default CodeMirror font-size 12px -- Stop storing last action via location hash and switch to using sessionStorage - -**Themes** - -- Make page selection a select and option instead of having a lot of page links -- Add the JSEnum class to create constants that can be accessed from webpack. Generate constants with `python manage.py build jsenums` -- Add the JinjaEnum class to inject constants into the Jinja environment to access from themes -- Update jQuery to 3.5.0 to resolve potential security issue -- Add some new CSS utilities (`.min-vh-*` and `.opacity-*`) -- Change some rows to have a minimum height so they don't render oddly without data -- Deprecate `.spinner-error` CSS class -- Deprecate accessing the type variable to check user role. Instead you should use `is_admin()` - -**Miscellaneous** - -- Enable foreign key enforcement for SQLite. Only really matters for the debug server. -- Remove the duplicated `get_config` from `CTFd.models` -- Fix possible email sending issues in Python 3 by using `EmailMessage` -- Dont set User type in the user side session. Instead it should be set in the new user attributes -- Fix flask-profiler and bump dependency to 1.8.1 -- Switch to using the `Faker` library for `populate.py` instead of hardcoded data -- Add a `yarn lint` command to run eslint on JS files -- Always insert the current CTFd version at the end of the import process -- Fix issue where files could not be downloaded on Windows - -# 2.3.3 / 2020-04-12 - -**General** - -- Re-enable the Jinja LRU Cache for **significant speedups** when returning HTML content - -**API** - -- `POST /api/v1/unlocks` will no longer allow duplicate unlocks to happen - -**Admin Panel** - -- Makes `Account Visibility` subtext clearer by explaining the `Private` setting in Config Panel - -**Themes** - -- Fixes an issue with using a theme with a purely numeric name -- Fixes issue where the scoreboard graph always said Teams regardless of mode - -**Miscellaneous** - -- Bump max log file size to 10 MB and fix log rotation -- Docker image dependencies (apk & pip) are no longer cached reducing the image size slightly - -# 2.3.2 / 2020-03-15 - -**General** - -- Fix awards not being properly assigned to teams in `TEAMS_MODE` - -**API** - -- Set `/api/v1/statistics/users` route to be admins_only -- When POST'ing to `/api/v1/awards`, CTFd will look up a user's team ID if `team_id` is not specified - -**Admin Panel** - -- Adds a setting to registration visibility to allow for MLC registration while registration is disabled -- Fix setting theme color during the setup flow and from the Admin Panel - -**Themes** - -- Fixes users/admins being able to remove profile settings. - - Previously a bug prevented users from removing some profile settings. Now the `core` theme stores the initial value of inputs as a `data` attribute and checks for changes when updating data. This should be a temporary hack until a proper front-end framework is in place. -- Fix `ezToast()` issue that was keeping toast messages visible indefinitely -- Fix `modal-body` parameters in ezq.js for `ezAlert` and `ezQuery` and fix the progress bar for certain cases in `ezProgressBar` -- Use `authed()` function to check if user is authed in `base.html`. This fixes an issue where a page could look as if the user was logged in. - -**Miscellaneous** - -- Fix behavior for `REVERSE_PROXY` config setting when set to a boolean instead of a string -- Improve `Dockerfile` to run fewer commands and re-use the build cache -- Add `make coverage` to generate an HTML coverage report -- Update `coverage` and `pytest-cov` development dependencies - -# 2.3.1 / 2020-02-17 - -**General** - -- User confirmation emails now have the correct URL format - -# 2.3.0 / 2020-02-17 - -**General** - -- During setup, admins can register their email address with the CTFd LLC newsletter for news and updates -- Fix editting hints from the admin panel -- Allow admins to insert HTML code directly into the header and footer (end of body tag) of pages. This replaces and supercedes the custom CSS feature. - - The `views.custom_css` route has been removed. -- Admins can now customize the content of outgoing emails and inject certain variables into email content. -- The `manage.py` script can now manipulate the CTFd Configs table via the `get_config` and `set_config` commands. (e.g. `python manage.py get_config ctf_theme` and `python manage.py set_config ctf_theme core`) - -**Themes** - -- Themes should now reference the `theme_header` and `theme_footer` configs instead of the `views.custom_css` endpoint to allow for user customizations. See the `base.html` file of the core theme. - -**Plugins** - -- Make `ezq` functions available to `CTFd.js` under `CTFd.ui.ezq` - -**Miscellaneous** - -- Python imports sorted with `isort` and import order enforced -- Black formatter running on a majority of Python code - -# 2.2.3 / 2020-01-21 - -### This release includes a critical security fix for CTFd versions >= 2.0.0 - -All CTFd administrators are recommended to take the following steps: - -1. Upgrade their installations to the latest version -2. Rotate the `SECRET_KEY` value -3. Reset the passwords for all administrator users - -**Security** - -- This release includes a fix for a vulnerability allowing an arbitrary user to take over other accounts given their username and a CTFd instance with emails enabled - -**General** - -- Users will receive an email notification when their passwords are reset -- Fixed an error when users provided incorrect team join information - -# 2.2.2 / 2020-01-09 - -**General** - -- Add jQuery, Moment, nunjucks, and Howl to window globals to make it easier for plugins to access JS modules -- Fix issue with timezone loading in config page which was preventing display of CTF times - -# 2.2.1 / 2020-01-04 - -**General** - -- Fix issue preventing admins from creating users or teams -- Fix issue importing backups that contained empty directories - -# 2.2.0 / 2019-12-22 - -## Notice - -2.2.0 focuses on updating the front end of CTFd to use more modern programming practices and changes some aspects of core CTFd design. If your current installation is using a custom theme or custom plugin with **_any_** kind of JavaScript, it is likely that you will need to upgrade that theme/plugin to be useable with v2.2.0. - -**General** - -- Team size limits can now be enforced from the configuration panel -- Access tokens functionality for API usage -- Admins can now choose how to deliver their notifications - - Toast (new default) - - Alert - - Background - - Sound On / Sound Off -- There is now a notification counter showing how many unread notifications were received -- Setup has been redesigned to have multiple steps - - Added Description - - Added Start time and End time, - - Added MajorLeagueCyber integration - - Added Theme and color selection -- Fixes issue where updating dynamic challenges could change the value to an incorrect value -- Properly use a less restrictive regex to validate email addresses -- Bump Python dependencies to latest working versions -- Admins can now give awards to team members from the team's admin panel page - -**API** - -- Team member removals (`DELETE /api/v1/teams/[team_id]/members`) from the admin panel will now delete the removed members's Submissions, Awards, Unlocks - -**Admin Panel** - -- Admins can now user a color input box to specify a theme color which is injected as part of the CSS configuration. Theme developers can use this CSS value to change colors and styles accordingly. -- Challenge updates will now alert you if the challenge doesn't have a flag -- Challenge entry now allows you to upload files and enter simple flags from the initial challenge creation page - -**Themes** - -- Significant JavaScript and CSS rewrite to use ES6, Webpack, yarn, and babel -- Theme asset specially generated URLs - - Static theme assets are now loaded with either .dev.extension or .min.extension depending on production or development (i.e. debug server) - - Static theme assets are also given a `d` GET parameter that changes per server start. Used to bust browser caches. -- Use `defer` for script tags to not block page rendering -- Only show the MajorLeagueCyber button if configured in configuration -- The admin panel now links to https://help.ctfd.io/ in the top right -- Create an `ezToast()` function to use [Bootstrap's toasts](https://getbootstrap.com/docs/4.3/components/toasts/) -- The user-facing navbar now features icons -- Awards shown on a user's profile can now have award icons -- The default MarkdownIt render created by CTFd will now open links in new tabs -- Country flags can now be shown on the user pages - -**Deployment** - -- Switch `Dockerfile` from `python:2.7-alpine` to `python:3.7-alpine` -- Add `SERVER_SENT_EVENTS` config value to control whether Notifications are enabled -- Challenge ID is now recorded in the submission log - -**Plugins** - -- Add an endpoint parameter to `register_plugin_assets_directory()` and `register_plugin_asset()` to control what endpoint Flask uses for the added route - -**Miscellaneous** - -- `CTFd.utils.email.sendmail()` now allows the caller to specify subject as an argument - - The subject allows for injecting custom variable via the new `CTFd.utils.formatters.safe_format()` function -- Admin user information is now error checked during setup -- Added yarn to the toolchain and the yarn dev, yarn build, yarn verify, and yarn clean scripts -- Prevent old CTFd imports from being imported - -# 2.1.5 / 2019-10-2 - -**General** - -- Fixes `flask run` debug server by not monkey patching in `wsgi.py` -- Fix CSV exports in Python 3 by converting StringIO to BytesIO -- Avoid exception on sessions without a valid user and force logout -- Fix several issues in Vagrant provisioning - -**API** - -- Prevent users from nulling out profile values and breaking certain pages - -# 2.1.4 / 2019-08-31 - -**General** - -- Make user pages show the team's score and place information instead of the user's information if in team mode -- Allow admins to search users by IP address -- Require password for email address changes in the user profile -- The place indicator in `Teams Mode` on the team pages and user pages now correctly excludes hidden teams -- Fix scoreboard place ordinalization in Python 3 -- Fix for a crash where imports will fail on SQLite due to lack of ALTER command support -- Fix for an issue where files downloaded via S3 would have the folder name in the filename -- Make `Users.get_place()` and `Teams.get_place()` for return None instead of 0 if the account has no rank/place -- Properly redirect users or 403 if the endpoint requires a team but the user isn't in one -- Show affiliation in user and team pages in the admin panel and public and private user and team pages - -**Themes** - -- Remove invalid `id='submit'` on submit buttons in various theme files -- Set `tabindex` to 0 since we don't really care for forcing tab order -- Rename `statistics.js` to `graphs.js` in the Admin Panel as it was identified that adblockers can sometimes block the file - -**API** - -- The team profile endpoint (`/api/v1/teams/me`) will now return 403 instead of 400 if the requesting user is not the captain -- The Challenge API will now properly freeze the solve count to freeze time - -# 2.1.3 / 2019-06-22 - -**General** - -- Fix issue with downloading files after CTF end when `view_after_ctf` is enabled -- Sort solves in admin challenge view by date -- Link to appropriate user and challenge in team, user, and challenge pages -- Redirect to `/team` instead of `/challenges` after a user registers in team mode -- Fixes bug where pages marked as `hidden` weren't loading -- Remove `data-href` from `pages.html` in the Admin Panel to fix the delete button -- Add UI to handle team member removal in Admin Panel -- Fixes account links on the scoreboard page created by `update()`. They now correctly point to the user instead of undefined when in user mode. -- `utils._get_config` will now return `KeyError` instead of `None` to avoid cache misses - -**Deployment** - -- Use `/dev/shm` for `--worker-tmp-dir` in gunicorn in Docker -- Cache `get_place` code for users and teams. -- Install `Flask-DebugToolbar` in development -- Cache the `/scoreboard` page to avoid having to rebuild the response so often -- Make unprivileged `ctfd` user usable for mysql connection in docker-compose by having the db image create the database instead of CTFd -- Fix bug causing apache2 + mod_wsgi deployments to break - -**API** - -- Change `/api/v1/teams/[team_id]/members` from taking `id` to `user_id`. - - Endpoint was unused so the API change is considered non-breaking. -- Add `account_type` and `account_url` field in `/api/v1/scoreboard` -- Separate `/api/v1/[users,teams]/[me,id]/[solves,fails,awards]` into seperate API endpoints -- Clear standings cache after award creation/deletion - -**Exports** - -- Temporarily disable foreign keys in MySQL, MariaDB, and Postgres during `import_ctf()` -- Add `cache_timeout` parameter to `send_file`response in `/admin/export` to prevent the browser from caching the export - -**Tests** - -- Fix score changing test helpers to clear standings cache when generating a score changing row - -# 2.1.2 / 2019-05-13 - -**General** - -- Fix freeze time regressions in 2.x - - Make `/api/v1/[users,teams]/[me]/[solves,fails,awards]` endpoints load as admin so users can see their solves after freeze - - Make `/api/v1/challenges/[id]/solves` only show solves before freeze time - - Add the `?preview=true` GET parameter for admins to preview challenges solves as a user -- Team join attempts are now ratelimited - -**Tests** - -- More linting and autoformatting rules - - Format Javascript and CSS files with `prettier`: `prettier --write 'CTFd/themes/**/*'` - - Format Python with `black`: `black CTFd` and `black tests` - - `make lint` and thus Travis now include the above commands as lint checks -- Travis now uses xenial instead of trusty. - -# 2.1.1 / 2019-05-04 - -**General** - -- Allow admins to hit `/api/v1/challenges` and `/api/v1/challenges/[id]` without having a team to fix challenge previews -- Fix rate-limiting of flag submission when using team mode -- Fixes some modal close buttons not working in the admin panel -- Fixes `populate.py` to assign captains to teams. - -**Models** - -- Added `Challenges.flags` relationship and moved the `Flags.challenge` relationship to a backref on Challenges -- Added `ondelete='CASCADE'` to most ForeignKeys in models allowing for deletions to remove associated data - - `Hints` should be deleted when their Challenge is deleted - - `Tags` should be deleted when their Challenge is deleted - - `Flags` should be deleted when their Challenge is deleted - - `ChallengeFiles` should be deleted when their Challenge is deleted - - Deletion of the file itself is not handled by the model/database - - `Awards` should be deleted when their user or team is deleted - - `Unlocks` should be deleted when their user or team is deleted - - `Tracking` should be deleted when their user or team is deleted - - `Teams.captain_id` should be set to NULL when the captain user is deleted - -**Exports** - -- Force `db.create_all()` to happen for imports on `sqlite` or on failure to create missing tables -- Force `ctf_theme` to be set to `core` in imports in case a theme is missing from the import or the instance -- Fix imports/exports to emit and accept JSON properly under MariaDB - - MariaDB does not properly understand JSON so it must accept strings instead of dicts - - MariaDB outputs strings instead of JSON for its JSON type so the export serializer will attempt to cast output JSON strings to JSON objects - -**Deployment** - -- Run as root when using docker-compose - - This is necessary to be able to write to the volumes mounted from the host - -# 2.1.0 / 2019-04-24 - -**General** - -- Remove Flask-SocketIO in favor of custom Server Side Events code - - Removed the Flask-SocketIO dependency and removed all related code. See **Deployment** section. - - Added EventSource polyfill from Yaffle/EventSource - - Events are now rate-limited and only availble to authenticated users - - This means real time notifications will only appear to authenticated users - - Browser localStorage is now used to dictate which tab will maintain the persistent connection to the `/events` endpoint - - Thanks to https://gist.github.com/neilj/4146038 - - Notifications (currently the only use of the events code) now appear with a notification sound - - Thanks to [Terrence Martin](https://soundcloud.com/tj-martin-composer) for the sound -- Added UI to delete and download files from the media library -- Progress bars have been added to some actions which could take time - - To file uploads on challenge page - - To file uploads on the page editor page - - To the import CTF functionality -- Challenge file downloads now require a token to download - - `/files/` now accepts a `?token=` parameter which is a serialized version of `{user_id: <>, team_id: <>, file_id: <>}` - - If any of these sections are invalid or the user/team is banned the download is blocked - - This allows files to be downloaded via `curl` or `wget` (i.e. without cookie authentication) -- Added a team captain concept. Team captains can edit team information such as name, team password, website, etc. - - Only captains can change their team's captain - - Captains are the first to join the team. But they can be transferred to the true captain later on -- Cache `/api/v1/scoreboard` and `/api/v1/scoreboard/top/[count]` - - Adds `cache.make_cache_key` because Flask-Caching is unable to cleanly determine the endpoint for Flask-Restplus - - This helper may change in a future release or be deprecated by an improvement in Flask-Caching -- Properly load hidden and banned properties in the admin team edit modal -- Adds a hover color change on table rows in the admin panel. - - If a table row specifies the `data-href` attribute it will become clickable -- Add a simple Makefile to wrap some basic commands - - make lint: lint the code base - - make test: test the code base - - make serve: create a debug application server - - make shell: create a Python shell with the application preloaded -- Started work on a Sphinx documentation site available at [https://docs.ctfd.io](https://docs.ctfd.io) - -**Dependencies** - -- Upgraded `SQLAlchemy` to 1.3.3 for proper JSON columns in SQLite -- Pin `Werkzeug==0.15.2` in requirements.txt -- Flask-Profiler added to `serve.py --profile` - -**Models** - -- Awards table now has a `type` column which is used as a polymorphic identity -- Add `Teams.captain_id` column to Teams table - -**API** - -- Added /api/v1/teams/[team_id]/members -- Cache `/api/v1/scoreboard` and `/api/v1/scoreboard/top/[count]` - - Adds `cache.make_cache_key` because Flask-Caching is unable to cleanly determine the endpoint for Flask-Restplus - - This helper may change in a future release or be deprecated by an improvement in Flask-Caching -- Add `/api/v1/users?notify=true` to email user & password after creating new account -- Fix issue where admins could not modify their own profile or their own team - -**Plugins** - -- `CTFd.utils.security.passwords` deprecated and now available at `CTFd.utils.crypto` -- Built-in challenge plugins now linkify challenge text properly -- Challenge type plugins do not have to append `script_root` to file downloads anymore as that will now be managed by the API -- Awards are now polymorphic and subtables can be created for them - -**Themes** - -- Fix spelling mistake in `500.html` -- Removed `socket.io.min.js` from `base.html` -- Added EventSource polyfill from Yaffle/EventSource -- Added `howler.js` to play notification sounds -- Vendored/duplicated files which were shared between the `admin` and `core` themes have been de-duped - - The files used in the `core` theme should now be considered free to use by other themes -- CTF start and end times are now injected into `base.html` and available in the `CTFd.js` object -- Register page now properly says "User Name" instead of "Team Name" since only users can see the Register page -- Users and Teams pages now use a public and private page. - - user.html -> users/public.html and users/private.html - - team.html -> teams/public.html and teams/private.html -- Separate `admin/templates/modals/users/create.html` into `admin/templates/modals/users/edit.html` - -**Exports** - -- Exports will now properly export JSON for all JSON columns - - In some configurations the column would be exported as a string. - - Legacy string columns will still be imported properly. -- Exports from old 2.x CTFd versions should upgrade and be installed properly - - Any failure to do so should be considered a bug - -**Deployment** - -- User is no longer `root` in Docker image - - Errors in writing log files will now fail silently as we expect a future rewrite - - Logs will now also go to stdout -- Update Dockerfile to create and chown/chmod the folders used by `docker-compose` to store files/logs (`/var/log/CTFd`, `/var/uploads`) - - This allows the container to write to the folder despite it being a volume mounted from the host -- Default worker changed back to `gevent` -- Removed Flask-SocketIO dependency - - Removed the `SOCKETIO_ASYNC_MODE` config -- `gevent` is now required to allow the Server Sent Events client polling code to work - - If you use the provided `wsgi.py` or `gevent` gunicorn workers, there shouldn't be any issues -- Cache `/api/v1/scoreboard` and `/api/v1/scoreboard/top/[count]` which is invalidated on new solves or every minute - -**Configuration** - -- Added `SWAGGER_UI` setting to config.py to control the existence of the `/api/v1/` Swagger UI documentation -- Removed the `SOCKETIO_ASYNC_MODE` config -- Renamed docstring that referenced `SQLALCHEMY_DATABASE_URI` to `DATABASE_URL` -- The `REVERSE_PROXY` configuration can be set to `True` or to a comma seperated string of integers (e.g. `1,1,1,1,1`) - - See https://werkzeug.palletsprojects.com/en/0.15.x/middleware/proxy_fix/#werkzeug.middleware.proxy_fix.ProxyFix - - For example to configure `x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1` specify `1,1,1,1,1` - -**Tests** - -- Tests are now executed in parallel - - When using a non-memory database, test helpers will now randomize the database name to be able to parallelize execution -- Test tool switched from `nosetests` to `pytest` -- Lint tool switched from `pycodestyle` to `flake8` -- Basic security checking added using `bandit` -- Allow `create_ctfd()` test helper to take app configuration as an argument - -# 2.0.6 / 2019-04-08 - -**Security** - -- Fixes an issue where user email addresses could be disclosed to non-admins - -**General** - -- Users/Teams set to hidden or banned are no longer visible by other users - - This affects the API and the main user interface. This does not affect admins -- Users without a Team can no longer view challenges when the CTF is in Team Mode - -# 2.0.5 / 2019-03-23 - -**Security** - -- Fixes an issue where user email addresses could be disclosed to non-admins - -**General** - -- Dockerfile now installs `linux-headers` package from apk -- Hidden teams are no longer visible publicly -- Fixes an issue where long content made it it difficult/impossible to edit flags and hints -- Fix for users not being able to edit portions of their own profile -- Fix for solves not being frozen for teams. -- Reimplement direct user email sending from the admin panel -- Fixes an issue where confirmation logs would report None instead of the user if the browser was unauthenticated -- Fixes an issue where SMTP server configuration (MAIL_SERVER, MAIL_PORT) were not being read from `config.py` -- Fixes for a user's place on their profile showing a different place than the scoreboard -- Fixes for an issue where dynamic challenge values would appear to change after being solved by a hidden user - -**Exports** - -- Exports are now saved on disk with `tempfile.NamedTemporaryFile()` instead of memory during creation -- After importing an export, CTFd will now recreate all tables it expects to be available. This resolves an issue where tables created by plugins would be missing after an import. - -# 2.0.4 / 2019-01-30 - -**General** - -- Block user & team name changes if name changes are disabled (Closes #835) -- Set accounts to unconfirmed if email is changed while `verify_emails` is enabled -- Only allow users to change their email to emails with domains in the whitelist. -- Add `email.check_email_is_whitelisted()` to verify that a user's email is whitelisted. -- Create a `get_config` wrapper around the internal `_get_config` to let us set a default config value (Closes #659) -- Remove `utils.get_app_config()` from memoization and also give it a `default` parameter -- Move `utils.logging.init_logs()` into `utils.initialization` and properly call `init_logs()` to save logs to the logs folder -- Block the creation of users/teams from MLC if registration_visibility is private -- Fix showing incorrect 'CTF has ended' error if `view_after_ctf` is set. -- Fix creating users from the admin panel while name changes are disabled. - -**API** - -- `/api/v1/teams/` now coerced to an int (i.e. `/api/v1/teams/`) - -**Deployment** - -- Re-add the `LOG_FOLDER` envvar to docker-compose so we don't try to write to the read-only host -- Stop gunicorn from logging to `LOG_FOLDER` in docker without explicit opt-in -- Add `ACCESS_LOG` and `ERROR_LOG` envvars to docker to specify where gunicorn will log to -- Allow `DATABASE_URL` to contain custom MySQL ports for `docker-entrypoint.sh` -- Drop `WORKERS` count to 1 to avoid dealing with Flask-SocketIO sticky sessions' -- Install `gevent-websocket` and use it by default until we have a better solution -- NOTE: In future releases, websockets functionality will likely be removed. (#852) - -# 2.0.3 / 2019-01-12 - -**Security Release** - -This release resolves a security issue that allowed malicious users to hijack admin browser sessions in certain browsers under certain configurations. - -The implemented fix is to require the new `CSRF-Token` header on state-changing requests with a Content-Type of application/json. -The same nonce used for standard POST requests is re-used for the `CSRF-Token` header. - -Because of the necessary changes to the API, the previously used call to `fetch()` in themes should now be replaced with `CTFd.fetch()`. - -**Security** - -- Require `CSRF-Token` header on all API requests. -- Require CSRF protection on all HTTP methods except `GET`, `HEAD`, `OPTIONS`, and `TRACE`. -- Default session cookie to `SameSite=Lax` -- Send initial user information request to MajorLeagueCyber over HTTPS - -**General** - -- Fix `update_check()` logic so that we don't accidentally remove the update notification. - -**Themes** - -- Remove explicit usage of `script_root` in public JS. - - In custom themes, use the `CTFd.fetch()` function (defined in `CTFd.js`) and properly register the url root and CSRF nonce in `base.html` as shown below: - ```javascript - var script_root = "{{ request.script_root }}"; - var csrf_nonce = "{{ nonce }}"; - CTFd.options.urlRoot = script_root; - CTFd.options.csrfNonce = csrf_nonce; - ``` -- Reduce required amount of parameters required for static theme files. - - i.e. `url_for('views.themes')` no longer requires the themes parameter. It now defaults to the currently in-use theme. - -# 2.0.2 / 2019-01-03 - -**General** - -- Fix regression where public challenges could not be attempted by unauthed users. -- Admin Config Panel UI no longer allows changing of user mode. -- Show notification titles and allow for deleting notifications - - Update notification UI in admin panel to be similar to the public-facing UI -- Fix subdirectory deployments in a generic manner by modifying `request.path` to combine both `request.script_root` and `request.path`. - - Also create a request preprocessor to redirect users into the true CTFd app when deploying on a subdirectory. - - Redirect to `request.full_path` instead of just `request.path`. -- Fix `TestingConfig.SAFE_MODE` not being reset between tests. -- Disable `value` input in dynamic challenge update field since we calculate it on the user's behalf. -- Fix displaying incorrect account link in the solves tab of a challenge modal. -- Pin `normality` version because of an upstream issue in `dataset`. -- Fix `500`'s when users submit non-integer values to `?page=1` - -**API** - -- Add `/api/v1/notifications/` to allow accessing notifactions by ID. - - This is currently public but will become permission based later in the future -- Add `account_url` field to the response of `/api/v1//solves` so the client knows where an account is located. - -**Plugins** - -- Add new plugin utilities to register javascript and css files for the admin panel. - - Also fixed issue where those scripts and files were shared between generated applications - -# 2.0.1 / 2018-12-09 - -2.0.1 is a patch release to fix regressions and bugs in 2.0.0. - -If you are upgrading from a version prior to 2.0.0 please read the 2.0.0 change notes for instructions on updating to -2.0.0 before updating to 2.0.1. - -**General** - -- Fix setting auth for `get_smtp()`. - - Add `MAIL_USEAUTH` to `config.py`. -- Add more mail documentation to `config.py`. -- Disable jinja cache properly by setting `cache_size` to 0 (#662) - Regression from 1.2.0. -- Fix downloading files as an anonymous user. -- Fix viewing challenges anonymously if they have empty requirements. Closes #789 - - Allow anonymous users to see see challenges with empty requirements or anonymized challenges -- Clean up admin mail settings to use new label/small structure -- Fix email confirmations and improve test. -- Fix password resets from double hashing passwords - -**Themes** - -- Change `confirm.html` to use the variable user instead of team - -**API** - -- Grant admin write access to verified field in UserSchema. -- Fix setting `mail_username`, `mail_password` -- Prevent overriding smtp attributes on config update -- Fix hint loading for admins by adding `/api/v1/hints/?preview=true` for use by admins -- Fixing a bug where prerequisites could not be set for dynamic challenges due to a division by zero error where defaults were being set unnecessarily. - -**Exports** - -- Fix syncing down an empty S3 bucket -- Fix `S3Uploader` in Python 3 and fix test -- Fix S3 sync function to only pull down files instead of trying to pull directories - -# 2.0.0 / 2018-12-02 - -2.0.0 is a _significant_, backwards-incompaitble release. - -Many unofficial plugins will not be supported in CTFd 2.0.0. If you're having trouble updating your plugins -please join [the CTFd Slack](https://slack.ctfd.io/) for help and discussion. - -If you are upgrading from a prior version be sure to make backups and have a reversion plan before upgrading. - -- If upgrading from 1.2.0 please make use of the `migrations/1_2_0_upgrade_2_0_0.py` script as follows: - 1. Make all necessary backups. Backup the database, uploads folder, and source code directory. - 2. Upgrade the source code directory (i.e. `git pull`) but do not run any updated code yet. - 3. Set the `DATABASE_URL` in `CTFd/config.py` to point to your existing CTFd database. - 4. Run the upgrade script from the CTFd root folder i.e. `python migrations/1_2_0_upgrade_2_0_0.py`. - - This migration script will attempt to migrate data inside the database to 2.0.0 but it cannot account for every situation. - - Examples of situations where you may need to manually migrate data: - - Tables/columns created by plugins - - Tables/columns created by forks - - Using databases which are not officially supported (e.g. sqlite, postgres) - 5. Setup the rest of CTFd (i.e. config.py), migrate/update any plugins, and run normally. -- If upgrading from a version before 1.2.0, please upgrade to 1.2.0 and then continue with the steps above. - -**General** - -- Seperation of Teams into Users and Teams. - - Use User Mode if you want users to register as themselves and play on their own. - - Use Team Mode if you want users to create and join teams to play together. -- Integration with MajorLeagueCyber (MLC). (https://majorleaguecyber.org) - - Organizers can register their event with MLC and will receive OAuth Client ID & Client Secret. - - Organizers can set those OAuth credentials in CTFd to allow users and teams to automatically register in a CTF. -- Data is now provided to the front-end via the REST API. (#551) - - Javascript uses `fetch()` to consume the REST API. -- Dynamic Challenges are built in. -- S3 backed uploading/downloading built in. (#661) -- Real time notifications/announcements. (#600) - - Uses long-polling instead of websockets to simplify deployment. -- Email address domain whitelisting. (#603) -- Database exporting to CSV. (#656) -- Imports/Exports rewritten to act as backups. - - Importing no longer stacks values. - - Exports are no longer partial. -- Reset CTF from config panel (Remove all users, solves, fails. i.e. only keep Challenge data.) (#639) -- Countries are pre-determined and selectable instead of being user-entered. - - Countries stored based on country code. - - Based on https://github.com/umpirsky/country-list/blob/master/data/en_US/country.csv. -- Sessions are no longer stored using secure cookies. (#658) - - Sessions are now stored server side in a cache (`filesystem` or `redis`) allowing for session revocation. - - In order to delete the cache during local development you can delete `CTfd/.data/filesystem_cache`. -- Challenges can now have requirements which must be met before the challenge can be seen/solved. -- Workshop mode, score hiding, registration hiding, challenge hiding have been changed to visibility settings. -- Users and Teams can now be banned preventing access to the CTF. -- Dockerfile improvements. - - WORKERS count in `docker-entrypoint.sh` defaults to 1. (#716) - - `docker-entrypoint.sh` exits on any error. (#717) -- Increased test coverage. -- Create `SAFE_MODE` configuration to disable loading of plugins. -- Migrations have been reset. - -**Themes** - -- Data is now provided to the front-end via the REST API. - - Javascript uses `fetch()` to consume the REST API. -- The admin theme is no longer considered seperated from the core theme and should always be together. -- Themes now use `url_for()` to generate URLs instead of hardcoding. -- socket.io (via long-polling) is used to connect to CTFd to receive notifications. -- `ctf_name()` renamed to `get_ctf_name()` in themes. -- `ctf_logo()` renamed to `get_ctf_logo()` in themes. -- `ctf_theme()` renamed to `get_ctf_theme()` in themes. -- Update Font-Awesome to 5.4.1. -- Update moment.js to 2.22.2. (#704) -- Workshop mode, score hiding, registration hiding, challenge hiding have been changed to visibility functions. - - `accounts_visible()`, `challenges_visible()`, `registration_visible()`, `scores_visible()` - -**Plugins** - -- Plugins are loaded in `sorted()` order -- Rename challenge type plugins to use `.html` and have simplified names. (create, update, view) -- Many functions have moved around because utils.py has been broken up and refactored. (#475) -- Marshmallow (https://marshmallow.readthedocs.io) is now used by the REST API to validate and serialize/deserialize API data. - - Marshmallow schemas and views are used to restrict SQLAlchemy columns to user roles. -- The REST API features swagger support but this requires more utilization internally. -- Errors can now be provided between routes and decoraters through message flashing. (CTFd.utils.helpers; get_errors, get_infos, info_for, error_for) -- Email registration regex relaxed. (#693) -- Many functions have moved and now have dedicated utils packages for their category. -- Create `SAFE_MODE` configuration to disable loading of plugins. - -# 1.2.0 / 2018-05-04 - -**General** - -- Updated to Flask 1.0 & switched documentation to suggest using `flask run` instead of `python serve.py`. -- Added the ability to make static & regex flags case insensitive. -- The `/chals` endpoint no longer lists the details of challenges. - - The `/chals/:id` endpoint is now used to load challenge information before display. -- Admins can now see what users have solved a given challenge from the admin panel. -- Fixed issue with imports extracting files outside of the CTFd directory. -- Added import zipfile validation and optional size restriction. -- The ctftime, authentication, and admin restrictions have been converted to decorators to improve code reuse. - - 403 is now a more common status code. Previously it only indicated CSRF failure, now it can indicate login failure - or other Forbidden access situations. -- Challenge previews now work consistently instead of occasionally failing to show. -- Tests are now randomly ordered with `nose-randomly`. - -**Themes** - -- Admins now have the ability to upload a CTF logo from the config panel. -- Switched from the `marked` library to `Markdown-It` for client side markdown rendering. - - This will break Challenge type plugins that override the markdown renderer since we are no longer using the marked renderers. -- Introduced the `ezpg()` JS function to make it easier to draw a progressbar modal. -- Introduced the `$.patch()` AJAX wrapper. -- Team names are truncated properly to 50 characters in `teams.html`. -- The admin panel now uses Bootstrap badges instead of buttons to indicate properties such as `admin`, `verified`, `visible`. - -**Plugins** - -- Challenge type plugins now use a global challenge object with exposed functions to specify how to display a challenge. - (`preRender()`, `render()`, `postRender()`, `submit()`). - - Challenge type plugins also have access to window.challenge.data which allow for the previously mentioned functions to - process challenge data and change logic accordingly. -- Challenge type plugins now get full control over how a challenge is displayed via the nunjucks files. -- Challenge plugins should now pass the entire flag/key object to a Custom flag type. - - This allows the flag type to make use of the data column to decide how to operate on the flag. This is used to implement - case insensitive flags. -- Challenge modals (`modal.njk`) now use `{{ description }}` instead of `{{ desc }}` properly aligning with the database schema. -- The update and create modals now inject data into the modal via nunjucks instead of client side Javascript. -- The `utils.base64decode()` & `utils.base64encode()` functions no longer expose url encoding/decoding parameters. - -# 1.1.4 / 2018-04-05 - -**General** - -- [SECURITY] Fixed XSS in team website. (#604) -- Fixed deleting challenges that have a hint associated. (#601) - -**Themes** - -- Removed "SVG with JavaScript" in favor of "Web Fonts with CSS". (#604) - -# 1.1.3 / 2018-03-26 - -**General** - -- [SECURITY] Fixed XSS in team name field on team deletion. (#592) -- Fixed an issue where MariaDB defaults in Docker Compose caused difficult to debug 500 errors. (#566) -- Improved Docker usage: - - Redis cache - - Configurable amount of workers - - Easier to access logs - - Plugin requirements are installed on image build. - - Switched from the default gunicorn synchronous worker to gevent -- Fixed an issue where ties would be broken incorrectly if there are challenges that are worth 0 points. (#577) -- Fixed update checks not happening on CTFd start. (#595) -- Removed the static_html handler to access raw HTML files. (#561) - - Pages is now the only supported means of accessing/creating a page. -- Removed uwsgi specific configuration files. -- Fixed issue with Docker image having a hard coded database host name. (#587) - -**Themes** - -- Fixed scrollbar showing on pages that are smaller than the screen size (#589) -- Fixed displaying the team rank while in workshop mode. (#590) -- Fixed flag modal not clearing when creating multiple new flags. (#594) - -**Plugins** - -- Add a utility decorator to allow routes to forego CSRF protection. (#596) - -# 1.1.2 / 2018-01-23 - -**General** - -- Fixed page links on subdirectory deployments -- Fixed challenge updating on subdirectory deployments -- Fixed broken icon buttons on Safari - -**Themes** - -- Upgraded to Bootstrap 4.0.0 -- Upgraded to jQuery 3.3.1 -- Upgraded to FontAwesome 5.0.4 - -# 1.1.1 / 2018-01-08 - -**General** - -- Fixed regression where users could not be promoted to admins or verified. -- Fixed two icons in the Media Library which were not updated to Font Awesome 5. -- Challenge previews now include tags, hints, and files. -- Fixed an issue where a page could not be published immediately after being saved. - -**Themes** - -- Upgraded to Bootstrap 4 Beta v3. No major changes needed by themes. -- Fixed issue where the frozen message was not centered in the team page. -- The JavaScript `update()` function now has a callback instead of being hardcoded. -- `chalboard.js` now passes `script_root` into the Nunjucks templates so that file downloads work properly under subdirectories. - -# 1.1.0 / 2017-12-22 - -**Themes** - -- The original theme has been replaced by the core theme. The core theme is written in Bootstrap v4.0.0-beta.2 and significantly reduces the amount of custom styles/classes used. -- Challenges can now be previewed from the admin panel. -- The modals to modify files, flags, tags, and hints are no longer controlled by Challenge Type Plugins and are defined in CTFd itself. -- The admin graphs and admin statistics pages have been combined. -- Percentage solved for challenges has been moved to the new statistics page. -- The scoregraph on the scoreboard has been cleaned up to better fit the page width. -- Score graphs now use user-specific colors. -- Hints can now be previewed from the admin panel. -- Various confirmation modals have been replaced with `ezq.js`, a simple Bootstrap modal wrapper. -- Fixed a bug where challenge buttons on the challenge board would load before being styled as solved. -- FontAwesome has been upgraded to FontAwesome 5. -- Themes are now rendered using the Jinja2 SandboxedEnvironment. - -**Database** - -- `Keys.key_type` has been renamed to `Keys.type`. -- Pages Improvements: - - Page previews are now independent of the editor page. - - Pages now have a title which refer to the link's name on the navbar. - - Pages can now be drafts which cannot be seen by regular users. - - Pages can now require authentication to view. - - CSS editing has been moved to the config panel. - -**Challenge Type Plugins** - -- Handlebars has been replaced with Nunjucks which means Challenge Type Plugins using Handlebars must be updated to work with 1.1.0 - -**General** - -- CTFs can now be paused to prevent solves. -- A new authed_only decorator is available to restrict pages to logged-in users. -- CTFd will now check for updates against `versioning.ctfd.io`. Admins will see in the admin panel that CTFd can be updated. -- A ratelimit function has been implemented. Authentication and email related functions are now ratelimited. -- Code coverage from codecov. -- Admins can now see the reason why an email to a team failed to send. -- SMTP email connections take priority over mailgun settings now. The opposite used to be true. -- The JavaScript `submitkey()` function now takes an optional callback. -- `utils.get_config()` no longer looks at `app.config` values. Instead use `utils.get_app_config()`. -- Only prompt about upgrades when running with a TTY. - -# 1.0.5 / 2017-10-25 - -- Challenge Type Plugins now have a static interface which should be implemented by all challenge types. - - Challenge Type Plugins are now self-contained in the plugin system meaning you no longer need to manipulate themes in order to register Challenge Type Plugins. - - Challenge Type plugins should implement the create, read, update, delete, attempt, solve, and fail static methods. - - Challenge Type plugins now use strings for both their IDs and names. - - Challenge Type plugins now contain references to their related modal template files. -- Plugins can now register directories and files to be served by CTFd - - `CTFd.plugins.register_plugin_assets_directory` registers a directory to be served - - `CTFd.plugins.register_plugin_asset` registers a file to be served -- Plugins can now add to the admin and user menu/nav bars - - Plugins can now add to the admin menu bar with `CTFd.plugins. register_admin_plugin_menu_bar` - - Plugins can now add to the user menu bar with `CTFd.plugins. register_user_page_menu_bar` -- Plugins should now use `config.json` to define plugin attributes in lieu of config.html. Backwards compatibility has been maintained. With `config.json`, plugins can now control where the user is linked to instead of being directed to config.html. -- The challenge type and key type columns are now strings. -- Some utils functions now have `CTFd.plugins` wrappers. -- There is now a `/team` endpoint which takes the user to their own public profile. -- Mail server username and passwords are no longer rendered in the Admin Config panel. -- Users can now see their own user graphs when scores are hidden. -- `prepare.sh` is now marked executable. -- Spinners are now properly removed if there is no data to display. - -**Always backup your database before upgrading!** - -# 1.0.4 / 2017-09-09 - -- Add spinners to the original theme for loading graphs -- Plugins can register global JS files with `utils.register_plugin_script()` -- Plugins can register global CSS files with `utils.register_plugin_stylesheet()` -- Challenge type plugins can now control the response to a user's input -- Vagrantfile! -- Containers functionality has been moved into a [plugin](https://github.com/CTFd/CTFd-Docker) -- Hide solves from the JSON endpoint when hiding scores. -- The `utils.get_config()` function now checks for lower case and upper case values specified in `config.py` -- Pages are now cached so that we don't hit the database every time we need to load a page. -- The /top/10 endpoint has been changed to group users by ID instead of by name. -- Admins are allowed to see and solve challenges before a CTF starts. -- The CTF time configuration UI has been fixed to allow for the removal of times. -- The score graph in the original theme is now sorted by score. -- Bug fixes - - Use strings to store IP addresses. - - Take into account awards when we calculate a user's place. - - Plugin configuration clears the cache. - - More logging inside of auth.py. - - Username and password in the SMTP mail configuration are now optional. - - Markdown in challenges has been fixed to it's pre-regression state and is easier to write. - - Improvements to Python 3 compatability. - - Variety of new tests to further test behavior. - - Fixed an old bug where users would incorrectly see a challenge with 0 solves. - -# 1.0.3 / 2017-07-01 - -- Increased Unicode support. Smileys everywhere 👌 - - MySQL charset defaults to utf8mb4 -- Pages feature now supports Markdown and the Pages editor has a preview button -- IPv6 support for users' IP addresses -- Theme switching no longer requires a server restart -- Admins can now search for teams in the admin panel -- The config.html page for plugins are now Jinja templates giving them much more functionality -- Hints are automatically unlocked once the CTF is finished -- Themes now have a dedicated themes folder -- Graphs are now transparent so that themes can style the background -- Tags are now inserted into the classes of challenge buttons on the default theme -- There is now an `override_template()` function allowing plugins to replace the content of any template loaded by CTFd -- Changes to the email confirmation flow and making confirmation email resending user controlled. - -# 1.0.2 / 2017-04-29 - -- Challenges can now have max attempts set on a per challenge level -- Setup now automatically logs you in as an admin. Don't leave your CTFs unconfigured! -- Tests are now executed by TravisCI! Help out by adding tests for functionality! -- CTFd now has it's own Github organization! -- From a plugin you can replace most of the utils functions used by CTFd. This allows plugins to replace even more functionality within CTFd -- CTFd now has a concept of Hints! -- You can now customize the challenge editting modals in the admin panel -- There are now links to social media pages where you can follow CTFd to track updates. -- CTFd now has the ability to export and import data. This lets you save your CTFs as zip files and redeploy them again and again. - -# 1.0.1 / 2017-03-08 - -- Challenge types - - This means CTFd now supports multiple kinds of challenges. - - Challenges are now modifiable with a plugin. -- Solve types - - This means CTFd now supports multiple kinds of flags/keys. - - The flag/key logic is now modifiable with a plugin. -- Plugins are now allowed a configuration page -- The formerly massive admin.py is separated out into easier to work on chunks -- Improved Dockerfile and associated docker-compose file -- Fixes Python 3 compatibility -- Fixes a variety of glitches reported by users - -- **Always backup database before upgrading!** - -# 1.0.0 / 2017-01-24 - -**Implemented enhancements:** - -- 1.0.0 release! Things work! -- Manage everything from a browser -- Run Containers -- Themes -- Plugins -- Database migrations - -**Closed issues:** - -- Closed out 94 issues before tagging 1.0.0 - -**Merged pull requests:** - -- Merged 42 pull requests before tagging 1.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index d1c325c2..00000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,21 +0,0 @@ -## How to contribute to CTFd - -#### **Did you find a bug?** - -- **Do not open up a GitHub issue if the bug is a security vulnerability in CTFd**. Instead [email the details to us at support@ctfd.io](mailto:support@ctfd.io). - -- **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/CTFd/CTFd/issues). - -- If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/CTFd/CTFd/issues/new). Be sure to fill out the issue template with a **title and clear description**, and as much relevant information as possible (e.g. deployment setup, browser version, etc). - -#### **Did you write a patch that fixes a bug or implements a new feature?** - -- Open a new pull request with the patch. - -- Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable. - -- Ensure all status checks pass. PR's with test failures will not be merged. PR's with insufficient coverage may be merged depending on the situation. - -#### **Did you fix whitespace, format code, or make a purely cosmetic patch?** - -Changes that are cosmetic in nature and do not add anything substantial to the stability, functionality, or testability of CTFd will generally not be accepted. diff --git a/CTFd/__init__.py b/CTFd/__init__.py deleted file mode 100644 index 43f7b32a..00000000 --- a/CTFd/__init__.py +++ /dev/null @@ -1,313 +0,0 @@ -import datetime -import os -import sys -import weakref -from distutils.version import StrictVersion - -import jinja2 -from flask import Flask, Request -from flask.helpers import safe_join -from flask_babel import Babel -from flask_migrate import upgrade -from jinja2 import FileSystemLoader -from jinja2.sandbox import SandboxedEnvironment -from werkzeug.middleware.proxy_fix import ProxyFix -from werkzeug.utils import cached_property - -import CTFd.utils.config -from CTFd import utils -from CTFd.constants.themes import ADMIN_THEME, DEFAULT_THEME -from CTFd.plugins import init_plugins -from CTFd.utils.crypto import sha256 -from CTFd.utils.initialization import ( - init_events, - init_logs, - init_request_processors, - init_template_filters, - init_template_globals, -) -from CTFd.utils.migrations import create_database, migrations, stamp_latest_revision -from CTFd.utils.sessions import CachingSessionInterface -from CTFd.utils.updates import update_check -from CTFd.utils.user import get_locale - -__version__ = "3.5.2" -__channel__ = "oss" - - -class CTFdRequest(Request): - @cached_property - def path(self): - """ - Hijack the original Flask request path because it does not account for subdirectory deployments in an intuitive - manner. We append script_root so that the path always points to the full path as seen in the browser. - e.g. /subdirectory/path/route vs /path/route - - :return: string - """ - return self.script_root + super(CTFdRequest, self).path - - -class CTFdFlask(Flask): - def __init__(self, *args, **kwargs): - """Overriden Jinja constructor setting a custom jinja_environment""" - self.jinja_environment = SandboxedBaseEnvironment - self.session_interface = CachingSessionInterface(key_prefix="session") - self.request_class = CTFdRequest - - # Store server start time - self.start_time = datetime.datetime.utcnow() - - # Create generally unique run identifier - self.run_id = sha256(str(self.start_time))[0:8] - Flask.__init__(self, *args, **kwargs) - - def create_jinja_environment(self): - """Overridden jinja environment constructor""" - return super(CTFdFlask, self).create_jinja_environment() - - -class SandboxedBaseEnvironment(SandboxedEnvironment): - """SandboxEnvironment that mimics the Flask BaseEnvironment""" - - def __init__(self, app, **options): - if "loader" not in options: - options["loader"] = app.create_global_jinja_loader() - SandboxedEnvironment.__init__(self, **options) - self.app = app - - def _load_template(self, name, globals): - if self.loader is None: - raise TypeError("no loader for this environment specified") - - # Add theme to the LRUCache cache key - cache_name = name - if name.startswith("admin/") is False: - theme = str(utils.get_config("ctf_theme")) - cache_name = theme + "/" + name - - # Rest of this code is copied from Jinja - # https://github.com/pallets/jinja/blob/master/src/jinja2/environment.py#L802-L815 - cache_key = (weakref.ref(self.loader), cache_name) - if self.cache is not None: - template = self.cache.get(cache_key) - if template is not None and ( - not self.auto_reload or template.is_up_to_date - ): - return template - template = self.loader.load(self, name, globals) - if self.cache is not None: - self.cache[cache_key] = template - return template - - -class ThemeLoader(FileSystemLoader): - """Custom FileSystemLoader that is aware of theme structure and config. - """ - - DEFAULT_THEMES_PATH = os.path.join(os.path.dirname(__file__), "themes") - _ADMIN_THEME_PREFIX = ADMIN_THEME + "/" - - def __init__( - self, - searchpath=DEFAULT_THEMES_PATH, - theme_name=None, - encoding="utf-8", - followlinks=False, - ): - super(ThemeLoader, self).__init__(searchpath, encoding, followlinks) - self.theme_name = theme_name - - def get_source(self, environment, template): - # Refuse to load `admin/*` from a loader not for the admin theme - # Because there is a single template loader, themes can essentially - # provide files for other themes. This could end up causing issues if - # an admin theme references a file that doesn't exist that a malicious - # theme provides. - if template.startswith(self._ADMIN_THEME_PREFIX): - if self.theme_name != ADMIN_THEME: - raise jinja2.TemplateNotFound(template) - template = template[len(self._ADMIN_THEME_PREFIX) :] - theme_name = self.theme_name or str(utils.get_config("ctf_theme")) - template = safe_join(theme_name, "templates", template) - return super(ThemeLoader, self).get_source(environment, template) - - -def confirm_upgrade(): - if sys.stdin.isatty(): - print("/*\\ CTFd has updated and must update the database! /*\\") - print("/*\\ Please backup your database before proceeding! /*\\") - print("/*\\ CTFd maintainers are not responsible for any data loss! /*\\") - if input("Run database migrations (Y/N)").lower().strip() == "y": # nosec B322 - return True - else: - print("/*\\ Ignored database migrations... /*\\") - return False - else: - return True - - -def run_upgrade(): - upgrade() - utils.set_config("ctf_version", __version__) - - -def create_app(config="CTFd.config.Config"): - app = CTFdFlask(__name__) - with app.app_context(): - app.config.from_object(config) - - loaders = [] - # We provide a `DictLoader` which may be used to override templates - app.overridden_templates = {} - loaders.append(jinja2.DictLoader(app.overridden_templates)) - # A `ThemeLoader` with no `theme_name` will load from the current theme - loaders.append(ThemeLoader()) - # If `THEME_FALLBACK` is set and true, we add another loader which will - # load from the `DEFAULT_THEME` - this mirrors the order implemented by - # `config.ctf_theme_candidates()` - if bool(app.config.get("THEME_FALLBACK")): - loaders.append(ThemeLoader(theme_name=DEFAULT_THEME)) - # All themes including admin can be accessed by prefixing their name - prefix_loader_dict = {ADMIN_THEME: ThemeLoader(theme_name=ADMIN_THEME)} - for theme_name in CTFd.utils.config.get_themes(): - prefix_loader_dict[theme_name] = ThemeLoader(theme_name=theme_name) - loaders.append(jinja2.PrefixLoader(prefix_loader_dict)) - # Plugin templates are also accessed via prefix but we just point a - # normal `FileSystemLoader` at the plugin tree rather than validating - # each plugin here (that happens later in `init_plugins()`). We - # deliberately don't add this to `prefix_loader_dict` defined above - # because to do so would break template loading from a theme called - # `prefix` (even though that'd be weird). - plugin_loader = jinja2.FileSystemLoader( - searchpath=os.path.join(app.root_path, "plugins"), followlinks=True - ) - loaders.append(jinja2.PrefixLoader({"plugins": plugin_loader})) - # Use a choice loader to find the first match from our list of loaders - app.jinja_loader = jinja2.ChoiceLoader(loaders) - - from CTFd.models import ( # noqa: F401 - Challenges, - Fails, - Files, - Flags, - Solves, - Tags, - Teams, - Tracking, - db, - ) - - url = create_database() - - # This allows any changes to the SQLALCHEMY_DATABASE_URI to get pushed back in - # This is mostly so we can force MySQL's charset - app.config["SQLALCHEMY_DATABASE_URI"] = str(url) - - # Register database - db.init_app(app) - - # Register Flask-Migrate - migrations.init_app(app, db) - - babel = Babel() - babel.locale_selector_func = get_locale - babel.init_app(app) - - # Alembic sqlite support is lacking so we should just create_all anyway - if url.drivername.startswith("sqlite"): - # Enable foreign keys for SQLite. This must be before the - # db.create_all call because tests use the in-memory SQLite - # database (each connection, including db creation, is a new db). - # https://docs.sqlalchemy.org/en/13/dialects/sqlite.html#foreign-key-support - from sqlalchemy import event - from sqlalchemy.engine import Engine - - @event.listens_for(Engine, "connect") - def set_sqlite_pragma(dbapi_connection, connection_record): - cursor = dbapi_connection.cursor() - cursor.execute("PRAGMA foreign_keys=ON") - cursor.close() - - db.create_all() - stamp_latest_revision() - else: - # This creates tables instead of db.create_all() - # Allows migrations to happen properly - upgrade() - - from CTFd.models import ma - - ma.init_app(app) - - app.db = db - app.VERSION = __version__ - app.CHANNEL = __channel__ - - from CTFd.cache import cache - - cache.init_app(app) - app.cache = cache - - reverse_proxy = app.config.get("REVERSE_PROXY") - if reverse_proxy: - if type(reverse_proxy) is str and "," in reverse_proxy: - proxyfix_args = [int(i) for i in reverse_proxy.split(",")] - app.wsgi_app = ProxyFix(app.wsgi_app, *proxyfix_args) - else: - app.wsgi_app = ProxyFix( - app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1 - ) - - version = utils.get_config("ctf_version") - - # Upgrading from an older version of CTFd - if version and (StrictVersion(version) < StrictVersion(__version__)): - if confirm_upgrade(): - run_upgrade() - else: - exit() - - if not version: - utils.set_config("ctf_version", __version__) - - if not utils.get_config("ctf_theme"): - utils.set_config("ctf_theme", DEFAULT_THEME) - - update_check(force=True) - - init_request_processors(app) - init_template_filters(app) - init_template_globals(app) - - # Importing here allows tests to use sensible names (e.g. api instead of api_bp) - from CTFd.admin import admin - from CTFd.api import api - from CTFd.auth import auth - from CTFd.challenges import challenges - from CTFd.errors import render_error - from CTFd.events import events - from CTFd.scoreboard import scoreboard - from CTFd.teams import teams - from CTFd.users import users - from CTFd.views import views - - app.register_blueprint(views) - app.register_blueprint(teams) - app.register_blueprint(users) - app.register_blueprint(challenges) - app.register_blueprint(scoreboard) - app.register_blueprint(auth) - app.register_blueprint(api) - app.register_blueprint(events) - - app.register_blueprint(admin) - - for code in {403, 404, 500, 502}: - app.register_error_handler(code, render_error) - - init_logs(app) - init_events(app) - init_plugins(app) - - return app diff --git a/CTFd/admin/__init__.py b/CTFd/admin/__init__.py deleted file mode 100644 index 824f8314..00000000 --- a/CTFd/admin/__init__.py +++ /dev/null @@ -1,257 +0,0 @@ -import csv # noqa: I001 -import datetime -import os -from io import StringIO - -from flask import Blueprint, abort -from flask import current_app as app -from flask import ( - jsonify, - redirect, - render_template, - render_template_string, - request, - send_file, - url_for, -) - -admin = Blueprint("admin", __name__) - -# isort:imports-firstparty -from CTFd.admin import challenges # noqa: F401,I001 -from CTFd.admin import notifications # noqa: F401,I001 -from CTFd.admin import pages # noqa: F401,I001 -from CTFd.admin import scoreboard # noqa: F401,I001 -from CTFd.admin import statistics # noqa: F401,I001 -from CTFd.admin import submissions # noqa: F401,I001 -from CTFd.admin import teams # noqa: F401,I001 -from CTFd.admin import users # noqa: F401,I001 -from CTFd.cache import ( - cache, - clear_challenges, - clear_config, - clear_pages, - clear_standings, -) -from CTFd.models import ( - Awards, - Challenges, - Configs, - Notifications, - Pages, - Solves, - Submissions, - Teams, - Tracking, - Unlocks, - Users, - db, -) -from CTFd.utils import config as ctf_config -from CTFd.utils import get_config, set_config -from CTFd.utils.csv import dump_csv, load_challenges_csv, load_teams_csv, load_users_csv -from CTFd.utils.decorators import admins_only -from CTFd.utils.exports import background_import_ctf -from CTFd.utils.exports import export_ctf as export_ctf_util -from CTFd.utils.security.auth import logout_user -from CTFd.utils.uploads import delete_file -from CTFd.utils.user import is_admin - - -@admin.route("/admin", methods=["GET"]) -def view(): - if is_admin(): - return redirect(url_for("admin.statistics")) - return redirect(url_for("auth.login")) - - -@admin.route("/admin/plugins/", methods=["GET", "POST"]) -@admins_only -def plugin(plugin): - if request.method == "GET": - plugins_path = os.path.join(app.root_path, "plugins") - - config_html_plugins = [ - name - for name in os.listdir(plugins_path) - if os.path.isfile(os.path.join(plugins_path, name, "config.html")) - ] - - if plugin in config_html_plugins: - config_html = open( - os.path.join(app.root_path, "plugins", plugin, "config.html") - ).read() - return render_template_string(config_html) - abort(404) - elif request.method == "POST": - for k, v in request.form.items(): - if k == "nonce": - continue - set_config(k, v) - with app.app_context(): - clear_config() - return "1" - - -@admin.route("/admin/import", methods=["GET", "POST"]) -@admins_only -def import_ctf(): - if request.method == "GET": - start_time = cache.get("import_start_time") - end_time = cache.get("import_end_time") - import_status = cache.get("import_status") - import_error = cache.get("import_error") - return render_template( - "admin/import.html", - start_time=start_time, - end_time=end_time, - import_status=import_status, - import_error=import_error, - ) - elif request.method == "POST": - backup = request.files["backup"] - background_import_ctf(backup) - return redirect(url_for("admin.import_ctf")) - - -@admin.route("/admin/export", methods=["GET", "POST"]) -@admins_only -def export_ctf(): - backup = export_ctf_util() - ctf_name = ctf_config.ctf_name() - day = datetime.datetime.now().strftime("%Y-%m-%d_%T") - full_name = u"{}.{}.zip".format(ctf_name, day) - return send_file( - backup, cache_timeout=-1, as_attachment=True, attachment_filename=full_name - ) - - -@admin.route("/admin/import/csv", methods=["POST"]) -@admins_only -def import_csv(): - csv_type = request.form["csv_type"] - # Try really hard to load data in properly no matter what nonsense Excel gave you - raw = request.files["csv_file"].stream.read() - try: - csvdata = raw.decode("utf-8-sig") - except UnicodeDecodeError: - try: - csvdata = raw.decode("cp1252") - except UnicodeDecodeError: - csvdata = raw.decode("latin-1") - csvfile = StringIO(csvdata) - - loaders = { - "challenges": load_challenges_csv, - "users": load_users_csv, - "teams": load_teams_csv, - } - - loader = loaders[csv_type] - reader = csv.DictReader(csvfile) - success = loader(reader) - if success is True: - return redirect(url_for("admin.config")) - else: - return jsonify(success), 500 - - -@admin.route("/admin/export/csv") -@admins_only -def export_csv(): - table = request.args.get("table") - - output = dump_csv(name=table) - - return send_file( - output, - as_attachment=True, - cache_timeout=-1, - attachment_filename="{name}-{table}.csv".format( - name=ctf_config.ctf_name(), table=table - ), - ) - - -@admin.route("/admin/config", methods=["GET", "POST"]) -@admins_only -def config(): - # Clear the config cache so that we don't get stale values - clear_config() - - configs = Configs.query.all() - configs = {c.key: get_config(c.key) for c in configs} - - themes = ctf_config.get_themes() - - # Remove current theme but ignore failure - try: - themes.remove(get_config("ctf_theme")) - except ValueError: - pass - - return render_template("admin/config.html", themes=themes, **configs) - - -@admin.route("/admin/reset", methods=["GET", "POST"]) -@admins_only -def reset(): - if request.method == "POST": - require_setup = False - logout = False - next_url = url_for("admin.statistics") - - data = request.form - - if data.get("pages"): - _pages = Pages.query.all() - for p in _pages: - for f in p.files: - delete_file(file_id=f.id) - - Pages.query.delete() - - if data.get("notifications"): - Notifications.query.delete() - - if data.get("challenges"): - _challenges = Challenges.query.all() - for c in _challenges: - for f in c.files: - delete_file(file_id=f.id) - Challenges.query.delete() - - if data.get("accounts"): - Users.query.delete() - Teams.query.delete() - require_setup = True - logout = True - - if data.get("submissions"): - Solves.query.delete() - Submissions.query.delete() - Awards.query.delete() - Unlocks.query.delete() - Tracking.query.delete() - - if require_setup: - set_config("setup", False) - cache.clear() - logout_user() - next_url = url_for("views.setup") - - db.session.commit() - - clear_pages() - clear_standings() - clear_challenges() - clear_config() - - if logout is True: - cache.clear() - logout_user() - - db.session.close() - return redirect(next_url) - - return render_template("admin/reset.html") diff --git a/CTFd/admin/challenges.py b/CTFd/admin/challenges.py deleted file mode 100644 index 973e7ca3..00000000 --- a/CTFd/admin/challenges.py +++ /dev/null @@ -1,78 +0,0 @@ -from flask import abort, render_template, request, url_for - -from CTFd.admin import admin -from CTFd.models import Challenges, Flags, Solves -from CTFd.plugins.challenges import CHALLENGE_CLASSES, get_chal_class -from CTFd.utils.decorators import admins_only - - -@admin.route("/admin/challenges") -@admins_only -def challenges_listing(): - q = request.args.get("q") - field = request.args.get("field") - filters = [] - - if q: - # The field exists as an exposed column - if Challenges.__mapper__.has_property(field): - filters.append(getattr(Challenges, field).like("%{}%".format(q))) - - query = Challenges.query.filter(*filters).order_by(Challenges.id.asc()) - challenges = query.all() - total = query.count() - - return render_template( - "admin/challenges/challenges.html", - challenges=challenges, - total=total, - q=q, - field=field, - ) - - -@admin.route("/admin/challenges/") -@admins_only -def challenges_detail(challenge_id): - challenges = dict( - Challenges.query.with_entities(Challenges.id, Challenges.name).all() - ) - challenge = Challenges.query.filter_by(id=challenge_id).first_or_404() - solves = ( - Solves.query.filter_by(challenge_id=challenge.id) - .order_by(Solves.date.asc()) - .all() - ) - flags = Flags.query.filter_by(challenge_id=challenge.id).all() - - try: - challenge_class = get_chal_class(challenge.type) - except KeyError: - abort( - 500, - f"The underlying challenge type ({challenge.type}) is not installed. This challenge can not be loaded.", - ) - - update_j2 = render_template( - challenge_class.templates["update"].lstrip("/"), challenge=challenge - ) - - update_script = url_for( - "views.static_html", route=challenge_class.scripts["update"].lstrip("/") - ) - return render_template( - "admin/challenges/challenge.html", - update_template=update_j2, - update_script=update_script, - challenge=challenge, - challenges=challenges, - solves=solves, - flags=flags, - ) - - -@admin.route("/admin/challenges/new") -@admins_only -def challenges_new(): - types = CHALLENGE_CLASSES.keys() - return render_template("admin/challenges/new.html", types=types) diff --git a/CTFd/admin/notifications.py b/CTFd/admin/notifications.py deleted file mode 100644 index 273416e3..00000000 --- a/CTFd/admin/notifications.py +++ /dev/null @@ -1,12 +0,0 @@ -from flask import render_template - -from CTFd.admin import admin -from CTFd.models import Notifications -from CTFd.utils.decorators import admins_only - - -@admin.route("/admin/notifications") -@admins_only -def notifications(): - notifs = Notifications.query.order_by(Notifications.id.desc()).all() - return render_template("admin/notifications.html", notifications=notifs) diff --git a/CTFd/admin/pages.py b/CTFd/admin/pages.py deleted file mode 100644 index 748bc29d..00000000 --- a/CTFd/admin/pages.py +++ /dev/null @@ -1,49 +0,0 @@ -from flask import render_template, request - -from CTFd.admin import admin -from CTFd.models import Pages -from CTFd.schemas.pages import PageSchema -from CTFd.utils import markdown -from CTFd.utils.decorators import admins_only - - -@admin.route("/admin/pages") -@admins_only -def pages_listing(): - pages = Pages.query.all() - return render_template("admin/pages.html", pages=pages) - - -@admin.route("/admin/pages/new") -@admins_only -def pages_new(): - return render_template("admin/editor.html") - - -@admin.route("/admin/pages/preview", methods=["POST"]) -@admins_only -def pages_preview(): - # We only care about content. - # Loading other attributes improperly will cause Marshmallow to incorrectly return a dict - data = { - "content": request.form.get("content"), - "format": request.form.get("format"), - } - schema = PageSchema() - page = schema.load(data) - return render_template("page.html", content=page.data.html) - - -@admin.route("/admin/pages/") -@admins_only -def pages_detail(page_id): - page = Pages.query.filter_by(id=page_id).first_or_404() - page_op = request.args.get("operation") - - if request.method == "GET" and page_op == "preview": - return render_template("page.html", content=markdown(page.content)) - - if request.method == "GET" and page_op == "create": - return render_template("admin/editor.html") - - return render_template("admin/editor.html", page=page) diff --git a/CTFd/admin/scoreboard.py b/CTFd/admin/scoreboard.py deleted file mode 100644 index c876814d..00000000 --- a/CTFd/admin/scoreboard.py +++ /dev/null @@ -1,16 +0,0 @@ -from flask import render_template - -from CTFd.admin import admin -from CTFd.utils.config import is_teams_mode -from CTFd.utils.decorators import admins_only -from CTFd.utils.scores import get_standings, get_user_standings - - -@admin.route("/admin/scoreboard") -@admins_only -def scoreboard_listing(): - standings = get_standings(admin=True) - user_standings = get_user_standings(admin=True) if is_teams_mode() else None - return render_template( - "admin/scoreboard.html", standings=standings, user_standings=user_standings - ) diff --git a/CTFd/admin/statistics.py b/CTFd/admin/statistics.py deleted file mode 100644 index cf629fd7..00000000 --- a/CTFd/admin/statistics.py +++ /dev/null @@ -1,87 +0,0 @@ -from flask import render_template - -from CTFd.admin import admin -from CTFd.models import Challenges, Fails, Solves, Teams, Tracking, Users, db -from CTFd.utils.decorators import admins_only -from CTFd.utils.modes import get_model -from CTFd.utils.updates import update_check - - -@admin.route("/admin/statistics", methods=["GET"]) -@admins_only -def statistics(): - update_check() - - Model = get_model() - - teams_registered = Teams.query.count() - users_registered = Users.query.count() - - wrong_count = ( - Fails.query.join(Model, Fails.account_id == Model.id) - .filter(Model.banned == False, Model.hidden == False) - .count() - ) - - solve_count = ( - Solves.query.join(Model, Solves.account_id == Model.id) - .filter(Model.banned == False, Model.hidden == False) - .count() - ) - - challenge_count = Challenges.query.count() - - total_points = ( - Challenges.query.with_entities(db.func.sum(Challenges.value).label("sum")) - .filter_by(state="visible") - .first() - .sum - ) or 0 - - ip_count = Tracking.query.with_entities(Tracking.ip).distinct().count() - - solves_sub = ( - db.session.query( - Solves.challenge_id, db.func.count(Solves.challenge_id).label("solves_cnt") - ) - .join(Model, Solves.account_id == Model.id) - .filter(Model.banned == False, Model.hidden == False) - .group_by(Solves.challenge_id) - .subquery() - ) - - solves = ( - db.session.query( - solves_sub.columns.challenge_id, - solves_sub.columns.solves_cnt, - Challenges.name, - ) - .join(Challenges, solves_sub.columns.challenge_id == Challenges.id) - .all() - ) - - solve_data = {} - for _chal, count, name in solves: - solve_data[name] = count - - most_solved = None - least_solved = None - if len(solve_data): - most_solved = max(solve_data, key=solve_data.get) - least_solved = min(solve_data, key=solve_data.get) - - db.session.close() - - return render_template( - "admin/statistics.html", - user_count=users_registered, - team_count=teams_registered, - ip_count=ip_count, - wrong_count=wrong_count, - solve_count=solve_count, - challenge_count=challenge_count, - total_points=total_points, - solve_data=solve_data, - most_solved=most_solved, - least_solved=least_solved, - ) diff --git a/CTFd/admin/submissions.py b/CTFd/admin/submissions.py deleted file mode 100644 index 273703db..00000000 --- a/CTFd/admin/submissions.py +++ /dev/null @@ -1,65 +0,0 @@ -from flask import render_template, request, url_for - -from CTFd.admin import admin -from CTFd.models import Challenges, Submissions -from CTFd.utils.decorators import admins_only -from CTFd.utils.helpers.models import build_model_filters -from CTFd.utils.modes import get_model - - -@admin.route("/admin/submissions", defaults={"submission_type": None}) -@admin.route("/admin/submissions/") -@admins_only -def submissions_listing(submission_type): - filters_by = {} - if submission_type: - filters_by["type"] = submission_type - filters = [] - - q = request.args.get("q") - field = request.args.get("field") - page = abs(request.args.get("page", 1, type=int)) - - filters = build_model_filters( - model=Submissions, - query=q, - field=field, - extra_columns={ - "challenge_name": Challenges.name, - "account_id": Submissions.account_id, - }, - ) - - Model = get_model() - - submissions = ( - Submissions.query.filter_by(**filters_by) - .filter(*filters) - .join(Challenges) - .join(Model) - .order_by(Submissions.date.desc()) - .paginate(page=page, per_page=50) - ) - - args = dict(request.args) - args.pop("page", 1) - - return render_template( - "admin/submissions.html", - submissions=submissions, - prev_page=url_for( - request.endpoint, - submission_type=submission_type, - page=submissions.prev_num, - **args - ), - next_page=url_for( - request.endpoint, - submission_type=submission_type, - page=submissions.next_num, - **args - ), - type=submission_type, - q=q, - field=field, - ) diff --git a/CTFd/admin/teams.py b/CTFd/admin/teams.py deleted file mode 100644 index 7d9cf0e6..00000000 --- a/CTFd/admin/teams.py +++ /dev/null @@ -1,86 +0,0 @@ -from flask import render_template, request, url_for -from sqlalchemy.sql import not_ - -from CTFd.admin import admin -from CTFd.models import Challenges, Teams, Tracking -from CTFd.utils.decorators import admins_only - - -@admin.route("/admin/teams") -@admins_only -def teams_listing(): - q = request.args.get("q") - field = request.args.get("field") - page = abs(request.args.get("page", 1, type=int)) - filters = [] - - if q: - # The field exists as an exposed column - if Teams.__mapper__.has_property(field): - filters.append(getattr(Teams, field).like("%{}%".format(q))) - - teams = ( - Teams.query.filter(*filters) - .order_by(Teams.id.asc()) - .paginate(page=page, per_page=50) - ) - - args = dict(request.args) - args.pop("page", 1) - - return render_template( - "admin/teams/teams.html", - teams=teams, - prev_page=url_for(request.endpoint, page=teams.prev_num, **args), - next_page=url_for(request.endpoint, page=teams.next_num, **args), - q=q, - field=field, - ) - - -@admin.route("/admin/teams/new") -@admins_only -def teams_new(): - return render_template("admin/teams/new.html") - - -@admin.route("/admin/teams/") -@admins_only -def teams_detail(team_id): - team = Teams.query.filter_by(id=team_id).first_or_404() - - # Get members - members = team.members - member_ids = [member.id for member in members] - - # Get Solves for all members - solves = team.get_solves(admin=True) - fails = team.get_fails(admin=True) - awards = team.get_awards(admin=True) - score = team.get_score(admin=True) - place = team.get_place(admin=True) - - # Get missing Challenges for all members - # TODO: How do you mark a missing challenge for a team? - solve_ids = [s.challenge_id for s in solves] - missing = Challenges.query.filter(not_(Challenges.id.in_(solve_ids))).all() - - # Get addresses for all members - addrs = ( - Tracking.query.filter(Tracking.user_id.in_(member_ids)) - .order_by(Tracking.date.desc()) - .all() - ) - - return render_template( - "admin/teams/team.html", - team=team, - members=members, - score=score, - place=place, - solves=solves, - fails=fails, - missing=missing, - awards=awards, - addrs=addrs, - ) diff --git a/CTFd/admin/users.py b/CTFd/admin/users.py deleted file mode 100644 index e7af7fc4..00000000 --- a/CTFd/admin/users.py +++ /dev/null @@ -1,109 +0,0 @@ -from flask import render_template, request, url_for -from sqlalchemy.sql import not_ - -from CTFd.admin import admin -from CTFd.models import Challenges, Tracking, Users -from CTFd.utils import get_config -from CTFd.utils.decorators import admins_only -from CTFd.utils.modes import TEAMS_MODE - - -@admin.route("/admin/users") -@admins_only -def users_listing(): - q = request.args.get("q") - field = request.args.get("field") - page = abs(request.args.get("page", 1, type=int)) - filters = [] - users = [] - - if q: - # The field exists as an exposed column - if Users.__mapper__.has_property(field): - filters.append(getattr(Users, field).like("%{}%".format(q))) - - if q and field == "ip": - users = ( - Users.query.join(Tracking, Users.id == Tracking.user_id) - .filter(Tracking.ip.like("%{}%".format(q))) - .order_by(Users.id.asc()) - .paginate(page=page, per_page=50) - ) - else: - users = ( - Users.query.filter(*filters) - .order_by(Users.id.asc()) - .paginate(page=page, per_page=50) - ) - - args = dict(request.args) - args.pop("page", 1) - - return render_template( - "admin/users/users.html", - users=users, - prev_page=url_for(request.endpoint, page=users.prev_num, **args), - next_page=url_for(request.endpoint, page=users.next_num, **args), - q=q, - field=field, - ) - - -@admin.route("/admin/users/new") -@admins_only -def users_new(): - return render_template("admin/users/new.html") - - -@admin.route("/admin/users/") -@admins_only -def users_detail(user_id): - # Get user object - user = Users.query.filter_by(id=user_id).first_or_404() - - # Get the user's solves - solves = user.get_solves(admin=True) - - # Get challenges that the user is missing - if get_config("user_mode") == TEAMS_MODE: - if user.team: - all_solves = user.team.get_solves(admin=True) - else: - all_solves = user.get_solves(admin=True) - else: - all_solves = user.get_solves(admin=True) - - solve_ids = [s.challenge_id for s in all_solves] - missing = Challenges.query.filter(not_(Challenges.id.in_(solve_ids))).all() - - # Get IP addresses that the User has used - addrs = ( - Tracking.query.filter_by(user_id=user_id).order_by(Tracking.date.desc()).all() - ) - - # Get Fails - fails = user.get_fails(admin=True) - - # Get Awards - awards = user.get_awards(admin=True) - - # Check if the user has an account (team or user) - # so that we don't throw an error if they dont - if user.account: - score = user.account.get_score(admin=True) - place = user.account.get_place(admin=True) - else: - score = None - place = None - - return render_template( - "admin/users/user.html", - solves=solves, - user=user, - addrs=addrs, - score=score, - missing=missing, - place=place, - fails=fails, - awards=awards, - ) diff --git a/CTFd/api/__init__.py b/CTFd/api/__init__.py deleted file mode 100644 index c2a4d50e..00000000 --- a/CTFd/api/__init__.py +++ /dev/null @@ -1,73 +0,0 @@ -from flask import Blueprint, current_app -from flask_restx import Api - -from CTFd.api.v1.awards import awards_namespace -from CTFd.api.v1.challenges import challenges_namespace -from CTFd.api.v1.comments import comments_namespace -from CTFd.api.v1.config import configs_namespace -from CTFd.api.v1.files import files_namespace -from CTFd.api.v1.flags import flags_namespace -from CTFd.api.v1.hints import hints_namespace -from CTFd.api.v1.notifications import notifications_namespace -from CTFd.api.v1.pages import pages_namespace -from CTFd.api.v1.schemas import ( - APIDetailedSuccessResponse, - APISimpleErrorResponse, - APISimpleSuccessResponse, -) -from CTFd.api.v1.scoreboard import scoreboard_namespace -from CTFd.api.v1.statistics import statistics_namespace -from CTFd.api.v1.submissions import submissions_namespace -from CTFd.api.v1.tags import tags_namespace -from CTFd.api.v1.teams import teams_namespace -from CTFd.api.v1.tokens import tokens_namespace -from CTFd.api.v1.topics import topics_namespace -from CTFd.api.v1.unlocks import unlocks_namespace -from CTFd.api.v1.users import users_namespace - -api = Blueprint("api", __name__, url_prefix="/api/v1") -CTFd_API_v1 = Api( - api, - version="v1", - doc=current_app.config.get("SWAGGER_UI_ENDPOINT"), - authorizations={ - "AccessToken": { - "type": "apiKey", - "in": "header", - "name": "Authorization", - "description": "Generate access token in the settings page of your user account.", - }, - "ContentType": { - "type": "apiKey", - "in": "header", - "name": "Content-Type", - "description": "Must be set to `application/json`", - }, - }, - security=["AccessToken", "ContentType"], -) - -CTFd_API_v1.schema_model("APISimpleErrorResponse", APISimpleErrorResponse.schema()) -CTFd_API_v1.schema_model( - "APIDetailedSuccessResponse", APIDetailedSuccessResponse.schema() -) -CTFd_API_v1.schema_model("APISimpleSuccessResponse", APISimpleSuccessResponse.schema()) - -CTFd_API_v1.add_namespace(challenges_namespace, "/challenges") -CTFd_API_v1.add_namespace(tags_namespace, "/tags") -CTFd_API_v1.add_namespace(topics_namespace, "/topics") -CTFd_API_v1.add_namespace(awards_namespace, "/awards") -CTFd_API_v1.add_namespace(hints_namespace, "/hints") -CTFd_API_v1.add_namespace(flags_namespace, "/flags") -CTFd_API_v1.add_namespace(submissions_namespace, "/submissions") -CTFd_API_v1.add_namespace(scoreboard_namespace, "/scoreboard") -CTFd_API_v1.add_namespace(teams_namespace, "/teams") -CTFd_API_v1.add_namespace(users_namespace, "/users") -CTFd_API_v1.add_namespace(statistics_namespace, "/statistics") -CTFd_API_v1.add_namespace(files_namespace, "/files") -CTFd_API_v1.add_namespace(notifications_namespace, "/notifications") -CTFd_API_v1.add_namespace(configs_namespace, "/configs") -CTFd_API_v1.add_namespace(pages_namespace, "/pages") -CTFd_API_v1.add_namespace(unlocks_namespace, "/unlocks") -CTFd_API_v1.add_namespace(tokens_namespace, "/tokens") -CTFd_API_v1.add_namespace(comments_namespace, "/comments") diff --git a/CTFd/api/v1/__init__.py b/CTFd/api/v1/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/CTFd/api/v1/awards.py b/CTFd/api/v1/awards.py deleted file mode 100644 index 77531a9a..00000000 --- a/CTFd/api/v1/awards.py +++ /dev/null @@ -1,177 +0,0 @@ -from typing import List - -from flask import request -from flask_restx import Namespace, Resource - -from CTFd.api.v1.helpers.request import validate_args -from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic -from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse -from CTFd.cache import clear_standings -from CTFd.constants import RawEnum -from CTFd.models import Awards, Users, db -from CTFd.schemas.awards import AwardSchema -from CTFd.utils.config import is_teams_mode -from CTFd.utils.decorators import admins_only -from CTFd.utils.helpers.models import build_model_filters - -awards_namespace = Namespace("awards", description="Endpoint to retrieve Awards") - -AwardModel = sqlalchemy_to_pydantic(Awards) - - -class AwardDetailedSuccessResponse(APIDetailedSuccessResponse): - data: AwardModel - - -class AwardListSuccessResponse(APIListSuccessResponse): - data: List[AwardModel] - - -awards_namespace.schema_model( - "AwardDetailedSuccessResponse", AwardDetailedSuccessResponse.apidoc() -) - -awards_namespace.schema_model( - "AwardListSuccessResponse", AwardListSuccessResponse.apidoc() -) - - -@awards_namespace.route("") -class AwardList(Resource): - @admins_only - @awards_namespace.doc( - description="Endpoint to list Award objects in bulk", - responses={ - 200: ("Success", "AwardListSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - @validate_args( - { - "user_id": (int, None), - "team_id": (int, None), - "type": (str, None), - "value": (int, None), - "category": (int, None), - "icon": (int, None), - "q": (str, None), - "field": ( - RawEnum( - "AwardFields", - { - "name": "name", - "description": "description", - "category": "category", - "icon": "icon", - }, - ), - None, - ), - }, - location="query", - ) - def get(self, query_args): - q = query_args.pop("q", None) - field = str(query_args.pop("field", None)) - filters = build_model_filters(model=Awards, query=q, field=field) - - awards = Awards.query.filter_by(**query_args).filter(*filters).all() - schema = AwardSchema(many=True) - response = schema.dump(awards) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return {"success": True, "data": response.data} - - @admins_only - @awards_namespace.doc( - description="Endpoint to create an Award object", - responses={ - 200: ("Success", "AwardListSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def post(self): - req = request.get_json() - - # Force a team_id if in team mode and unspecified - if is_teams_mode(): - team_id = req.get("team_id") - if team_id is None: - user = Users.query.filter_by(id=req["user_id"]).first_or_404() - if user.team_id is None: - return ( - { - "success": False, - "errors": { - "team_id": [ - "User doesn't have a team to associate award with" - ] - }, - }, - 400, - ) - req["team_id"] = user.team_id - - schema = AwardSchema() - - response = schema.load(req, session=db.session) - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - db.session.add(response.data) - db.session.commit() - - response = schema.dump(response.data) - db.session.close() - - # Delete standings cache because awards can change scores - clear_standings() - - return {"success": True, "data": response.data} - - -@awards_namespace.route("/") -@awards_namespace.param("award_id", "An Award ID") -class Award(Resource): - @admins_only - @awards_namespace.doc( - description="Endpoint to get a specific Award object", - responses={ - 200: ("Success", "AwardDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def get(self, award_id): - award = Awards.query.filter_by(id=award_id).first_or_404() - response = AwardSchema().dump(award) - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return {"success": True, "data": response.data} - - @admins_only - @awards_namespace.doc( - description="Endpoint to delete an Award object", - responses={200: ("Success", "APISimpleSuccessResponse")}, - ) - def delete(self, award_id): - award = Awards.query.filter_by(id=award_id).first_or_404() - db.session.delete(award) - db.session.commit() - db.session.close() - - # Delete standings cache because awards can change scores - clear_standings() - - return {"success": True} diff --git a/CTFd/api/v1/challenges.py b/CTFd/api/v1/challenges.py deleted file mode 100644 index d035221b..00000000 --- a/CTFd/api/v1/challenges.py +++ /dev/null @@ -1,813 +0,0 @@ -from typing import List # noqa: I001 - -from flask import abort, render_template, request, url_for -from flask_restx import Namespace, Resource -from sqlalchemy.sql import and_ - -from CTFd.api.v1.helpers.request import validate_args -from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic -from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse -from CTFd.cache import clear_challenges, clear_standings -from CTFd.constants import RawEnum -from CTFd.models import ChallengeFiles as ChallengeFilesModel -from CTFd.models import Challenges -from CTFd.models import ChallengeTopics as ChallengeTopicsModel -from CTFd.models import Fails, Flags, Hints, HintUnlocks, Solves, Submissions, Tags, db -from CTFd.plugins.challenges import CHALLENGE_CLASSES, get_chal_class -from CTFd.schemas.challenges import ChallengeSchema -from CTFd.schemas.flags import FlagSchema -from CTFd.schemas.hints import HintSchema -from CTFd.schemas.tags import TagSchema -from CTFd.utils import config, get_config -from CTFd.utils import user as current_user -from CTFd.utils.challenges import ( - get_all_challenges, - get_solve_counts_for_challenges, - get_solve_ids_for_user_id, - get_solves_for_challenge_id, -) -from CTFd.utils.config.visibility import ( - accounts_visible, - challenges_visible, - scores_visible, -) -from CTFd.utils.dates import ctf_ended, ctf_paused, ctftime -from CTFd.utils.decorators import ( - admins_only, - during_ctf_time_only, - require_verified_emails, -) -from CTFd.utils.decorators.visibility import ( - check_challenge_visibility, - check_score_visibility, -) -from CTFd.utils.logging import log -from CTFd.utils.security.signing import serialize -from CTFd.utils.user import ( - authed, - get_current_team, - get_current_team_attrs, - get_current_user, - get_current_user_attrs, - is_admin, -) - -challenges_namespace = Namespace( - "challenges", description="Endpoint to retrieve Challenges" -) - -ChallengeModel = sqlalchemy_to_pydantic( - Challenges, include={"solves": int, "solved_by_me": bool} -) -TransientChallengeModel = sqlalchemy_to_pydantic(Challenges, exclude=["id"]) - - -class ChallengeDetailedSuccessResponse(APIDetailedSuccessResponse): - data: ChallengeModel - - -class ChallengeListSuccessResponse(APIListSuccessResponse): - data: List[ChallengeModel] - - -challenges_namespace.schema_model( - "ChallengeDetailedSuccessResponse", ChallengeDetailedSuccessResponse.apidoc() -) - -challenges_namespace.schema_model( - "ChallengeListSuccessResponse", ChallengeListSuccessResponse.apidoc() -) - - -@challenges_namespace.route("") -class ChallengeList(Resource): - @check_challenge_visibility - @during_ctf_time_only - @require_verified_emails - @challenges_namespace.doc( - description="Endpoint to get Challenge objects in bulk", - responses={ - 200: ("Success", "ChallengeListSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - @validate_args( - { - "name": (str, None), - "max_attempts": (int, None), - "value": (int, None), - "category": (str, None), - "type": (str, None), - "state": (str, None), - "q": (str, None), - "field": ( - RawEnum( - "ChallengeFields", - { - "name": "name", - "description": "description", - "category": "category", - "type": "type", - "state": "state", - }, - ), - None, - ), - }, - location="query", - ) - def get(self, query_args): - # Require a team if in teams mode - # TODO: Convert this into a re-useable decorator - # TODO: The require_team decorator doesnt work because of no admin passthru - if get_current_user_attrs(): - if is_admin(): - pass - else: - if config.is_teams_mode() and get_current_team_attrs() is None: - abort(403) - - # Build filtering queries - q = query_args.pop("q", None) - field = str(query_args.pop("field", None)) - - # Admins get a shortcut to see all challenges despite pre-requisites - admin_view = is_admin() and request.args.get("view") == "admin" - - # Get a cached mapping of challenge_id to solve_count - solve_counts = get_solve_counts_for_challenges(admin=admin_view) - - # Get list of solve_ids for current user - if authed(): - user = get_current_user() - user_solves = get_solve_ids_for_user_id(user_id=user.id) - else: - user_solves = set() - - # Aggregate the query results into the hashes defined at the top of - # this block for later use - if scores_visible() and accounts_visible(): - solve_count_dfl = 0 - else: - # Empty out the solves_count if we're hiding scores/accounts - solve_counts = {} - # This is necessary to match the challenge detail API which returns - # `None` for the solve count if visiblity checks fail - solve_count_dfl = None - - chal_q = get_all_challenges(admin=admin_view, field=field, q=q, **query_args) - - # Iterate through the list of challenges, adding to the object which - # will be JSONified back to the client - response = [] - tag_schema = TagSchema(view="user", many=True) - - # Gather all challenge IDs so that we can determine invalid challenge prereqs - all_challenge_ids = { - c.id for c in Challenges.query.with_entities(Challenges.id).all() - } - for challenge in chal_q: - if challenge.requirements: - requirements = challenge.requirements.get("prerequisites", []) - anonymize = challenge.requirements.get("anonymize") - prereqs = set(requirements).intersection(all_challenge_ids) - if user_solves >= prereqs or admin_view: - pass - else: - if anonymize: - response.append( - { - "id": challenge.id, - "type": "hidden", - "name": "???", - "value": 0, - "solves": None, - "solved_by_me": False, - "category": "???", - "tags": [], - "template": "", - "script": "", - } - ) - # Fallthrough to continue - continue - - try: - challenge_type = get_chal_class(challenge.type) - except KeyError: - # Challenge type does not exist. Fall through to next challenge. - continue - - # Challenge passes all checks, add it to response - response.append( - { - "id": challenge.id, - "type": challenge_type.name, - "name": challenge.name, - "value": challenge.value, - "solves": solve_counts.get(challenge.id, solve_count_dfl), - "solved_by_me": challenge.id in user_solves, - "category": challenge.category, - "tags": tag_schema.dump(challenge.tags).data, - "template": challenge_type.templates["view"], - "script": challenge_type.scripts["view"], - } - ) - - db.session.close() - return {"success": True, "data": response} - - @admins_only - @challenges_namespace.doc( - description="Endpoint to create a Challenge object", - responses={ - 200: ("Success", "ChallengeDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def post(self): - data = request.form or request.get_json() - - # Load data through schema for validation but not for insertion - schema = ChallengeSchema() - response = schema.load(data) - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - challenge_type = data["type"] - challenge_class = get_chal_class(challenge_type) - challenge = challenge_class.create(request) - response = challenge_class.read(challenge) - - clear_challenges() - - return {"success": True, "data": response} - - -@challenges_namespace.route("/types") -class ChallengeTypes(Resource): - @admins_only - def get(self): - response = {} - - for class_id in CHALLENGE_CLASSES: - challenge_class = CHALLENGE_CLASSES.get(class_id) - response[challenge_class.id] = { - "id": challenge_class.id, - "name": challenge_class.name, - "templates": challenge_class.templates, - "scripts": challenge_class.scripts, - "create": render_template( - challenge_class.templates["create"].lstrip("/") - ), - } - return {"success": True, "data": response} - - -@challenges_namespace.route("/") -class Challenge(Resource): - @check_challenge_visibility - @during_ctf_time_only - @require_verified_emails - @challenges_namespace.doc( - description="Endpoint to get a specific Challenge object", - responses={ - 200: ("Success", "ChallengeDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def get(self, challenge_id): - if is_admin(): - chal = Challenges.query.filter(Challenges.id == challenge_id).first_or_404() - else: - chal = Challenges.query.filter( - Challenges.id == challenge_id, - and_(Challenges.state != "hidden", Challenges.state != "locked"), - ).first_or_404() - - try: - chal_class = get_chal_class(chal.type) - except KeyError: - abort( - 500, - f"The underlying challenge type ({chal.type}) is not installed. This challenge can not be loaded.", - ) - - if chal.requirements: - requirements = chal.requirements.get("prerequisites", []) - anonymize = chal.requirements.get("anonymize") - # Gather all challenge IDs so that we can determine invalid challenge prereqs - all_challenge_ids = { - c.id for c in Challenges.query.with_entities(Challenges.id).all() - } - if challenges_visible(): - user = get_current_user() - if user: - solve_ids = ( - Solves.query.with_entities(Solves.challenge_id) - .filter_by(account_id=user.account_id) - .order_by(Solves.challenge_id.asc()) - .all() - ) - else: - # We need to handle the case where a user is viewing challenges anonymously - solve_ids = [] - solve_ids = {value for value, in solve_ids} - prereqs = set(requirements).intersection(all_challenge_ids) - if solve_ids >= prereqs or is_admin(): - pass - else: - if anonymize: - return { - "success": True, - "data": { - "id": chal.id, - "type": "hidden", - "name": "???", - "value": 0, - "solves": None, - "solved_by_me": False, - "category": "???", - "tags": [], - "template": "", - "script": "", - }, - } - abort(403) - else: - abort(403) - - tags = [ - tag["value"] for tag in TagSchema("user", many=True).dump(chal.tags).data - ] - - unlocked_hints = set() - hints = [] - if authed(): - user = get_current_user() - team = get_current_team() - - # TODO: Convert this into a re-useable decorator - if is_admin(): - pass - else: - if config.is_teams_mode() and team is None: - abort(403) - - unlocked_hints = { - u.target - for u in HintUnlocks.query.filter_by( - type="hints", account_id=user.account_id - ) - } - files = [] - for f in chal.files: - token = { - "user_id": user.id, - "team_id": team.id if team else None, - "file_id": f.id, - } - files.append( - url_for("views.files", path=f.location, token=serialize(token)) - ) - else: - files = [url_for("views.files", path=f.location) for f in chal.files] - - for hint in Hints.query.filter_by(challenge_id=chal.id).all(): - if hint.id in unlocked_hints or ctf_ended(): - hints.append( - {"id": hint.id, "cost": hint.cost, "content": hint.content} - ) - else: - hints.append({"id": hint.id, "cost": hint.cost}) - - response = chal_class.read(challenge=chal) - - # Get list of solve_ids for current user - if authed(): - user = get_current_user() - user_solves = get_solve_ids_for_user_id(user_id=user.id) - else: - user_solves = [] - - solves_count = get_solve_counts_for_challenges(challenge_id=chal.id) - if solves_count: - challenge_id = chal.id - solve_count = solves_count.get(chal.id) - solved_by_user = challenge_id in user_solves - else: - solve_count, solved_by_user = 0, False - - # Hide solve counts if we are hiding solves/accounts - if scores_visible() is False or accounts_visible() is False: - solve_count = None - - if authed(): - # Get current attempts for the user - attempts = Submissions.query.filter_by( - account_id=user.account_id, challenge_id=challenge_id - ).count() - else: - attempts = 0 - - response["solves"] = solve_count - response["solved_by_me"] = solved_by_user - response["attempts"] = attempts - response["files"] = files - response["tags"] = tags - response["hints"] = hints - - response["view"] = render_template( - chal_class.templates["view"].lstrip("/"), - solves=solve_count, - solved_by_me=solved_by_user, - files=files, - tags=tags, - hints=[Hints(**h) for h in hints], - max_attempts=chal.max_attempts, - attempts=attempts, - challenge=chal, - ) - - db.session.close() - return {"success": True, "data": response} - - @admins_only - @challenges_namespace.doc( - description="Endpoint to edit a specific Challenge object", - responses={ - 200: ("Success", "ChallengeDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def patch(self, challenge_id): - data = request.get_json() - - # Load data through schema for validation but not for insertion - schema = ChallengeSchema() - response = schema.load(data) - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - challenge = Challenges.query.filter_by(id=challenge_id).first_or_404() - challenge_class = get_chal_class(challenge.type) - challenge = challenge_class.update(challenge, request) - response = challenge_class.read(challenge) - - clear_standings() - clear_challenges() - - return {"success": True, "data": response} - - @admins_only - @challenges_namespace.doc( - description="Endpoint to delete a specific Challenge object", - responses={200: ("Success", "APISimpleSuccessResponse")}, - ) - def delete(self, challenge_id): - challenge = Challenges.query.filter_by(id=challenge_id).first_or_404() - chal_class = get_chal_class(challenge.type) - chal_class.delete(challenge) - - clear_standings() - clear_challenges() - - return {"success": True} - - -@challenges_namespace.route("/attempt") -class ChallengeAttempt(Resource): - @check_challenge_visibility - @during_ctf_time_only - @require_verified_emails - def post(self): - if authed() is False: - return {"success": True, "data": {"status": "authentication_required"}}, 403 - - if request.content_type != "application/json": - request_data = request.form - else: - request_data = request.get_json() - - challenge_id = request_data.get("challenge_id") - - if current_user.is_admin(): - preview = request.args.get("preview", False) - if preview: - challenge = Challenges.query.filter_by(id=challenge_id).first_or_404() - chal_class = get_chal_class(challenge.type) - status, message = chal_class.attempt(challenge, request) - - return { - "success": True, - "data": { - "status": "correct" if status else "incorrect", - "message": message, - }, - } - - if ctf_paused(): - return ( - { - "success": True, - "data": { - "status": "paused", - "message": "{} is paused".format(config.ctf_name()), - }, - }, - 403, - ) - - user = get_current_user() - team = get_current_team() - - # TODO: Convert this into a re-useable decorator - if config.is_teams_mode() and team is None: - abort(403) - - fails = Fails.query.filter_by( - account_id=user.account_id, challenge_id=challenge_id - ).count() - - challenge = Challenges.query.filter_by(id=challenge_id).first_or_404() - - if challenge.state == "hidden": - abort(404) - - if challenge.state == "locked": - abort(403) - - if challenge.requirements: - requirements = challenge.requirements.get("prerequisites", []) - solve_ids = ( - Solves.query.with_entities(Solves.challenge_id) - .filter_by(account_id=user.account_id) - .order_by(Solves.challenge_id.asc()) - .all() - ) - solve_ids = {solve_id for solve_id, in solve_ids} - # Gather all challenge IDs so that we can determine invalid challenge prereqs - all_challenge_ids = { - c.id for c in Challenges.query.with_entities(Challenges.id).all() - } - prereqs = set(requirements).intersection(all_challenge_ids) - if solve_ids >= prereqs: - pass - else: - abort(403) - - chal_class = get_chal_class(challenge.type) - - # Anti-bruteforce / submitting Flags too quickly - kpm = current_user.get_wrong_submissions_per_minute(user.account_id) - kpm_limit = int(get_config("incorrect_submissions_per_min", default=10)) - if kpm > kpm_limit: - if ctftime(): - chal_class.fail( - user=user, team=team, challenge=challenge, request=request - ) - log( - "submissions", - "[{date}] {name} submitted {submission} on {challenge_id} with kpm {kpm} [TOO FAST]", - name=user.name, - submission=request_data.get("submission", "").encode("utf-8"), - challenge_id=challenge_id, - kpm=kpm, - ) - # Submitting too fast - return ( - { - "success": True, - "data": { - "status": "ratelimited", - "message": "You're submitting flags too fast. Slow down.", - }, - }, - 429, - ) - - solves = Solves.query.filter_by( - account_id=user.account_id, challenge_id=challenge_id - ).first() - - # Challenge not solved yet - if not solves: - # Hit max attempts - max_tries = challenge.max_attempts - if max_tries and fails >= max_tries > 0: - return ( - { - "success": True, - "data": { - "status": "incorrect", - "message": "You have 0 tries remaining", - }, - }, - 403, - ) - - status, message = chal_class.attempt(challenge, request) - if status: # The challenge plugin says the input is right - if ctftime() or current_user.is_admin(): - chal_class.solve( - user=user, team=team, challenge=challenge, request=request - ) - clear_standings() - clear_challenges() - - log( - "submissions", - "[{date}] {name} submitted {submission} on {challenge_id} with kpm {kpm} [CORRECT]", - name=user.name, - submission=request_data.get("submission", "").encode("utf-8"), - challenge_id=challenge_id, - kpm=kpm, - ) - return { - "success": True, - "data": {"status": "correct", "message": message}, - } - else: # The challenge plugin says the input is wrong - if ctftime() or current_user.is_admin(): - chal_class.fail( - user=user, team=team, challenge=challenge, request=request - ) - clear_standings() - clear_challenges() - - log( - "submissions", - "[{date}] {name} submitted {submission} on {challenge_id} with kpm {kpm} [WRONG]", - name=user.name, - submission=request_data.get("submission", "").encode("utf-8"), - challenge_id=challenge_id, - kpm=kpm, - ) - - if max_tries: - # Off by one since fails has changed since it was gotten - attempts_left = max_tries - fails - 1 - tries_str = "tries" - if attempts_left == 1: - tries_str = "try" - # Add a punctuation mark if there isn't one - if message[-1] not in "!().;?[]{}": - message = message + "." - return { - "success": True, - "data": { - "status": "incorrect", - "message": "{} You have {} {} remaining.".format( - message, attempts_left, tries_str - ), - }, - } - else: - return { - "success": True, - "data": {"status": "incorrect", "message": message}, - } - - # Challenge already solved - else: - log( - "submissions", - "[{date}] {name} submitted {submission} on {challenge_id} with kpm {kpm} [ALREADY SOLVED]", - name=user.name, - submission=request_data.get("submission", "").encode("utf-8"), - challenge_id=challenge_id, - kpm=kpm, - ) - return { - "success": True, - "data": { - "status": "already_solved", - "message": "You already solved this", - }, - } - - -@challenges_namespace.route("//solves") -class ChallengeSolves(Resource): - @check_challenge_visibility - @check_score_visibility - @during_ctf_time_only - @require_verified_emails - def get(self, challenge_id): - response = [] - challenge = Challenges.query.filter_by(id=challenge_id).first_or_404() - - # TODO: Need a generic challenge visibility call. - # However, it should be stated that a solve on a gated challenge is not considered private. - if challenge.state == "hidden" and is_admin() is False: - abort(404) - - freeze = get_config("freeze") - if freeze: - preview = request.args.get("preview") - if (is_admin() is False) or (is_admin() is True and preview): - freeze = True - elif is_admin() is True: - freeze = False - - response = get_solves_for_challenge_id(challenge_id=challenge_id, freeze=freeze) - - return {"success": True, "data": response} - - -@challenges_namespace.route("//files") -class ChallengeFiles(Resource): - @admins_only - def get(self, challenge_id): - response = [] - - challenge_files = ChallengeFilesModel.query.filter_by( - challenge_id=challenge_id - ).all() - - for f in challenge_files: - response.append({"id": f.id, "type": f.type, "location": f.location}) - return {"success": True, "data": response} - - -@challenges_namespace.route("//tags") -class ChallengeTags(Resource): - @admins_only - def get(self, challenge_id): - response = [] - - tags = Tags.query.filter_by(challenge_id=challenge_id).all() - - for t in tags: - response.append( - {"id": t.id, "challenge_id": t.challenge_id, "value": t.value} - ) - return {"success": True, "data": response} - - -@challenges_namespace.route("//topics") -class ChallengeTopics(Resource): - @admins_only - def get(self, challenge_id): - response = [] - - topics = ChallengeTopicsModel.query.filter_by(challenge_id=challenge_id).all() - - for t in topics: - response.append( - { - "id": t.id, - "challenge_id": t.challenge_id, - "topic_id": t.topic_id, - "value": t.topic.value, - } - ) - return {"success": True, "data": response} - - -@challenges_namespace.route("//hints") -class ChallengeHints(Resource): - @admins_only - def get(self, challenge_id): - hints = Hints.query.filter_by(challenge_id=challenge_id).all() - schema = HintSchema(many=True) - response = schema.dump(hints) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return {"success": True, "data": response.data} - - -@challenges_namespace.route("//flags") -class ChallengeFlags(Resource): - @admins_only - def get(self, challenge_id): - flags = Flags.query.filter_by(challenge_id=challenge_id).all() - schema = FlagSchema(many=True) - response = schema.dump(flags) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return {"success": True, "data": response.data} - - -@challenges_namespace.route("//requirements") -class ChallengeRequirements(Resource): - @admins_only - def get(self, challenge_id): - challenge = Challenges.query.filter_by(id=challenge_id).first_or_404() - return {"success": True, "data": challenge.requirements} diff --git a/CTFd/api/v1/comments.py b/CTFd/api/v1/comments.py deleted file mode 100644 index 4b3fac44..00000000 --- a/CTFd/api/v1/comments.py +++ /dev/null @@ -1,159 +0,0 @@ -from typing import List - -from flask import request, session -from flask_restx import Namespace, Resource - -from CTFd.api.v1.helpers.request import validate_args -from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic -from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse -from CTFd.constants import RawEnum -from CTFd.models import ( - ChallengeComments, - Comments, - PageComments, - TeamComments, - UserComments, - db, -) -from CTFd.schemas.comments import CommentSchema -from CTFd.utils.decorators import admins_only -from CTFd.utils.helpers.models import build_model_filters - -comments_namespace = Namespace("comments", description="Endpoint to retrieve Comments") - - -CommentModel = sqlalchemy_to_pydantic(Comments) - - -class CommentDetailedSuccessResponse(APIDetailedSuccessResponse): - data: CommentModel - - -class CommentListSuccessResponse(APIListSuccessResponse): - data: List[CommentModel] - - -comments_namespace.schema_model( - "CommentDetailedSuccessResponse", CommentDetailedSuccessResponse.apidoc() -) - -comments_namespace.schema_model( - "CommentListSuccessResponse", CommentListSuccessResponse.apidoc() -) - - -def get_comment_model(data): - model = Comments - if "challenge_id" in data: - model = ChallengeComments - elif "user_id" in data: - model = UserComments - elif "team_id" in data: - model = TeamComments - elif "page_id" in data: - model = PageComments - else: - model = Comments - return model - - -@comments_namespace.route("") -class CommentList(Resource): - @admins_only - @comments_namespace.doc( - description="Endpoint to list Comment objects in bulk", - responses={ - 200: ("Success", "CommentListSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - @validate_args( - { - "challenge_id": (int, None), - "user_id": (int, None), - "team_id": (int, None), - "page_id": (int, None), - "q": (str, None), - "field": (RawEnum("CommentFields", {"content": "content"}), None), - }, - location="query", - ) - def get(self, query_args): - q = query_args.pop("q", None) - field = str(query_args.pop("field", None)) - CommentModel = get_comment_model(data=query_args) - filters = build_model_filters(model=CommentModel, query=q, field=field) - - comments = ( - CommentModel.query.filter_by(**query_args) - .filter(*filters) - .order_by(CommentModel.id.desc()) - .paginate(max_per_page=100) - ) - schema = CommentSchema(many=True) - response = schema.dump(comments.items) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return { - "meta": { - "pagination": { - "page": comments.page, - "next": comments.next_num, - "prev": comments.prev_num, - "pages": comments.pages, - "per_page": comments.per_page, - "total": comments.total, - } - }, - "success": True, - "data": response.data, - } - - @admins_only - @comments_namespace.doc( - description="Endpoint to create a Comment object", - responses={ - 200: ("Success", "CommentDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def post(self): - req = request.get_json() - # Always force author IDs to be the actual user - req["author_id"] = session["id"] - CommentModel = get_comment_model(data=req) - - m = CommentModel(**req) - db.session.add(m) - db.session.commit() - - schema = CommentSchema() - - response = schema.dump(m) - db.session.close() - - return {"success": True, "data": response.data} - - -@comments_namespace.route("/") -class Comment(Resource): - @admins_only - @comments_namespace.doc( - description="Endpoint to delete a specific Comment object", - responses={200: ("Success", "APISimpleSuccessResponse")}, - ) - def delete(self, comment_id): - comment = Comments.query.filter_by(id=comment_id).first_or_404() - db.session.delete(comment) - db.session.commit() - db.session.close() - - return {"success": True} diff --git a/CTFd/api/v1/config.py b/CTFd/api/v1/config.py deleted file mode 100644 index 6401508a..00000000 --- a/CTFd/api/v1/config.py +++ /dev/null @@ -1,286 +0,0 @@ -from typing import List - -from flask import request -from flask_restx import Namespace, Resource - -from CTFd.api.v1.helpers.request import validate_args -from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic -from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse -from CTFd.cache import clear_challenges, clear_config, clear_standings -from CTFd.constants import RawEnum -from CTFd.models import Configs, Fields, db -from CTFd.schemas.config import ConfigSchema -from CTFd.schemas.fields import FieldSchema -from CTFd.utils import set_config -from CTFd.utils.decorators import admins_only -from CTFd.utils.helpers.models import build_model_filters - -configs_namespace = Namespace("configs", description="Endpoint to retrieve Configs") - -ConfigModel = sqlalchemy_to_pydantic(Configs) - - -class ConfigDetailedSuccessResponse(APIDetailedSuccessResponse): - data: ConfigModel - - -class ConfigListSuccessResponse(APIListSuccessResponse): - data: List[ConfigModel] - - -configs_namespace.schema_model( - "ConfigDetailedSuccessResponse", ConfigDetailedSuccessResponse.apidoc() -) - -configs_namespace.schema_model( - "ConfigListSuccessResponse", ConfigListSuccessResponse.apidoc() -) - - -@configs_namespace.route("") -class ConfigList(Resource): - @admins_only - @configs_namespace.doc( - description="Endpoint to get Config objects in bulk", - responses={ - 200: ("Success", "ConfigListSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - @validate_args( - { - "key": (str, None), - "value": (str, None), - "q": (str, None), - "field": (RawEnum("ConfigFields", {"key": "key", "value": "value"}), None), - }, - location="query", - ) - def get(self, query_args): - q = query_args.pop("q", None) - field = str(query_args.pop("field", None)) - filters = build_model_filters(model=Configs, query=q, field=field) - - configs = Configs.query.filter_by(**query_args).filter(*filters).all() - schema = ConfigSchema(many=True) - response = schema.dump(configs) - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return {"success": True, "data": response.data} - - @admins_only - @configs_namespace.doc( - description="Endpoint to get create a Config object", - responses={ - 200: ("Success", "ConfigDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def post(self): - req = request.get_json() - schema = ConfigSchema() - response = schema.load(req) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - db.session.add(response.data) - db.session.commit() - - response = schema.dump(response.data) - db.session.close() - - clear_config() - clear_standings() - clear_challenges() - - return {"success": True, "data": response.data} - - @admins_only - @configs_namespace.doc( - description="Endpoint to get patch Config objects in bulk", - responses={200: ("Success", "APISimpleSuccessResponse")}, - ) - def patch(self): - req = request.get_json() - schema = ConfigSchema() - - for key, value in req.items(): - response = schema.load({"key": key, "value": value}) - if response.errors: - return {"success": False, "errors": response.errors}, 400 - set_config(key=key, value=value) - - clear_config() - clear_standings() - clear_challenges() - - return {"success": True} - - -@configs_namespace.route("/") -class Config(Resource): - @admins_only - @configs_namespace.doc( - description="Endpoint to get a specific Config object", - responses={ - 200: ("Success", "ConfigDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def get(self, config_key): - config = Configs.query.filter_by(key=config_key).first_or_404() - schema = ConfigSchema() - response = schema.dump(config) - return {"success": True, "data": response.data} - - @admins_only - @configs_namespace.doc( - description="Endpoint to edit a specific Config object", - responses={ - 200: ("Success", "ConfigDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def patch(self, config_key): - config = Configs.query.filter_by(key=config_key).first() - data = request.get_json() - if config: - schema = ConfigSchema(instance=config, partial=True) - response = schema.load(data) - else: - schema = ConfigSchema() - data["key"] = config_key - response = schema.load(data) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - db.session.add(response.data) - db.session.commit() - - response = schema.dump(response.data) - db.session.close() - - clear_config() - clear_standings() - clear_challenges() - - return {"success": True, "data": response.data} - - @admins_only - @configs_namespace.doc( - description="Endpoint to delete a Config object", - responses={200: ("Success", "APISimpleSuccessResponse")}, - ) - def delete(self, config_key): - config = Configs.query.filter_by(key=config_key).first_or_404() - - db.session.delete(config) - db.session.commit() - db.session.close() - - clear_config() - clear_standings() - clear_challenges() - - return {"success": True} - - -@configs_namespace.route("/fields") -class FieldList(Resource): - @admins_only - @validate_args( - { - "type": (str, None), - "q": (str, None), - "field": (RawEnum("FieldFields", {"description": "description"}), None), - }, - location="query", - ) - def get(self, query_args): - q = query_args.pop("q", None) - field = str(query_args.pop("field", None)) - filters = build_model_filters(model=Fields, query=q, field=field) - - fields = Fields.query.filter_by(**query_args).filter(*filters).all() - schema = FieldSchema(many=True) - - response = schema.dump(fields) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return {"success": True, "data": response.data} - - @admins_only - def post(self): - req = request.get_json() - schema = FieldSchema() - response = schema.load(req, session=db.session) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - db.session.add(response.data) - db.session.commit() - - response = schema.dump(response.data) - db.session.close() - - return {"success": True, "data": response.data} - - -@configs_namespace.route("/fields/") -class Field(Resource): - @admins_only - def get(self, field_id): - field = Fields.query.filter_by(id=field_id).first_or_404() - schema = FieldSchema() - - response = schema.dump(field) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return {"success": True, "data": response.data} - - @admins_only - def patch(self, field_id): - field = Fields.query.filter_by(id=field_id).first_or_404() - schema = FieldSchema() - - req = request.get_json() - - response = schema.load(req, session=db.session, instance=field) - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - db.session.commit() - - response = schema.dump(response.data) - db.session.close() - - return {"success": True, "data": response.data} - - @admins_only - def delete(self, field_id): - field = Fields.query.filter_by(id=field_id).first_or_404() - db.session.delete(field) - db.session.commit() - db.session.close() - - return {"success": True} diff --git a/CTFd/api/v1/files.py b/CTFd/api/v1/files.py deleted file mode 100644 index d1ad5ebb..00000000 --- a/CTFd/api/v1/files.py +++ /dev/null @@ -1,144 +0,0 @@ -from typing import List - -from flask import request -from flask_restx import Namespace, Resource - -from CTFd.api.v1.helpers.request import validate_args -from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic -from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse -from CTFd.constants import RawEnum -from CTFd.models import Files, db -from CTFd.schemas.files import FileSchema -from CTFd.utils import uploads -from CTFd.utils.decorators import admins_only -from CTFd.utils.helpers.models import build_model_filters - -files_namespace = Namespace("files", description="Endpoint to retrieve Files") - -FileModel = sqlalchemy_to_pydantic(Files) - - -class FileDetailedSuccessResponse(APIDetailedSuccessResponse): - data: FileModel - - -class FileListSuccessResponse(APIListSuccessResponse): - data: List[FileModel] - - -files_namespace.schema_model( - "FileDetailedSuccessResponse", FileDetailedSuccessResponse.apidoc() -) - -files_namespace.schema_model( - "FileListSuccessResponse", FileListSuccessResponse.apidoc() -) - - -@files_namespace.route("") -class FilesList(Resource): - @admins_only - @files_namespace.doc( - description="Endpoint to get file objects in bulk", - responses={ - 200: ("Success", "FileListSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - @validate_args( - { - "type": (str, None), - "location": (str, None), - "q": (str, None), - "field": ( - RawEnum("FileFields", {"type": "type", "location": "location"}), - None, - ), - }, - location="query", - ) - def get(self, query_args): - q = query_args.pop("q", None) - field = str(query_args.pop("field", None)) - filters = build_model_filters(model=Files, query=q, field=field) - - files = Files.query.filter_by(**query_args).filter(*filters).all() - schema = FileSchema(many=True) - response = schema.dump(files) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return {"success": True, "data": response.data} - - @admins_only - @files_namespace.doc( - description="Endpoint to get file objects in bulk", - responses={ - 200: ("Success", "FileDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def post(self): - files = request.files.getlist("file") - # challenge_id - # page_id - - objs = [] - for f in files: - # uploads.upload_file(file=f, chalid=req.get('challenge')) - obj = uploads.upload_file(file=f, **request.form.to_dict()) - objs.append(obj) - - schema = FileSchema(many=True) - response = schema.dump(objs) - - if response.errors: - return {"success": False, "errors": response.errorss}, 400 - - return {"success": True, "data": response.data} - - -@files_namespace.route("/") -class FilesDetail(Resource): - @admins_only - @files_namespace.doc( - description="Endpoint to get a specific file object", - responses={ - 200: ("Success", "FileDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def get(self, file_id): - f = Files.query.filter_by(id=file_id).first_or_404() - schema = FileSchema() - response = schema.dump(f) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return {"success": True, "data": response.data} - - @admins_only - @files_namespace.doc( - description="Endpoint to delete a file object", - responses={200: ("Success", "APISimpleSuccessResponse")}, - ) - def delete(self, file_id): - f = Files.query.filter_by(id=file_id).first_or_404() - - uploads.delete_file(file_id=f.id) - db.session.delete(f) - db.session.commit() - db.session.close() - - return {"success": True} diff --git a/CTFd/api/v1/flags.py b/CTFd/api/v1/flags.py deleted file mode 100644 index e8b4aa9e..00000000 --- a/CTFd/api/v1/flags.py +++ /dev/null @@ -1,193 +0,0 @@ -from typing import List - -from flask import request -from flask_restx import Namespace, Resource - -from CTFd.api.v1.helpers.request import validate_args -from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic -from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse -from CTFd.constants import RawEnum -from CTFd.models import Flags, db -from CTFd.plugins.flags import FLAG_CLASSES, get_flag_class -from CTFd.schemas.flags import FlagSchema -from CTFd.utils.decorators import admins_only -from CTFd.utils.helpers.models import build_model_filters - -flags_namespace = Namespace("flags", description="Endpoint to retrieve Flags") - -FlagModel = sqlalchemy_to_pydantic(Flags) - - -class FlagDetailedSuccessResponse(APIDetailedSuccessResponse): - data: FlagModel - - -class FlagListSuccessResponse(APIListSuccessResponse): - data: List[FlagModel] - - -flags_namespace.schema_model( - "FlagDetailedSuccessResponse", FlagDetailedSuccessResponse.apidoc() -) - -flags_namespace.schema_model( - "FlagListSuccessResponse", FlagListSuccessResponse.apidoc() -) - - -@flags_namespace.route("") -class FlagList(Resource): - @admins_only - @flags_namespace.doc( - description="Endpoint to list Flag objects in bulk", - responses={ - 200: ("Success", "FlagListSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - @validate_args( - { - "challenge_id": (int, None), - "type": (str, None), - "content": (str, None), - "data": (str, None), - "q": (str, None), - "field": ( - RawEnum( - "FlagFields", {"type": "type", "content": "content", "data": "data"} - ), - None, - ), - }, - location="query", - ) - def get(self, query_args): - q = query_args.pop("q", None) - field = str(query_args.pop("field", None)) - filters = build_model_filters(model=Flags, query=q, field=field) - - flags = Flags.query.filter_by(**query_args).filter(*filters).all() - schema = FlagSchema(many=True) - response = schema.dump(flags) - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return {"success": True, "data": response.data} - - @admins_only - @flags_namespace.doc( - description="Endpoint to create a Flag object", - responses={ - 200: ("Success", "FlagDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def post(self): - req = request.get_json() - schema = FlagSchema() - response = schema.load(req, session=db.session) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - db.session.add(response.data) - db.session.commit() - - response = schema.dump(response.data) - db.session.close() - - return {"success": True, "data": response.data} - - -@flags_namespace.route("/types", defaults={"type_name": None}) -@flags_namespace.route("/types/") -class FlagTypes(Resource): - @admins_only - def get(self, type_name): - if type_name: - flag_class = get_flag_class(type_name) - response = {"name": flag_class.name, "templates": flag_class.templates} - return {"success": True, "data": response} - else: - response = {} - for class_id in FLAG_CLASSES: - flag_class = FLAG_CLASSES.get(class_id) - response[class_id] = { - "name": flag_class.name, - "templates": flag_class.templates, - } - return {"success": True, "data": response} - - -@flags_namespace.route("/") -class Flag(Resource): - @admins_only - @flags_namespace.doc( - description="Endpoint to get a specific Flag object", - responses={ - 200: ("Success", "FlagDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def get(self, flag_id): - flag = Flags.query.filter_by(id=flag_id).first_or_404() - schema = FlagSchema() - response = schema.dump(flag) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - response.data["templates"] = get_flag_class(flag.type).templates - - return {"success": True, "data": response.data} - - @admins_only - @flags_namespace.doc( - description="Endpoint to delete a specific Flag object", - responses={200: ("Success", "APISimpleSuccessResponse")}, - ) - def delete(self, flag_id): - flag = Flags.query.filter_by(id=flag_id).first_or_404() - - db.session.delete(flag) - db.session.commit() - db.session.close() - - return {"success": True} - - @admins_only - @flags_namespace.doc( - description="Endpoint to edit a specific Flag object", - responses={ - 200: ("Success", "FlagDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def patch(self, flag_id): - flag = Flags.query.filter_by(id=flag_id).first_or_404() - schema = FlagSchema() - req = request.get_json() - - response = schema.load(req, session=db.session, instance=flag, partial=True) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - db.session.commit() - - response = schema.dump(response.data) - db.session.close() - - return {"success": True, "data": response.data} diff --git a/CTFd/api/v1/helpers/__init__.py b/CTFd/api/v1/helpers/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/CTFd/api/v1/helpers/models.py b/CTFd/api/v1/helpers/models.py deleted file mode 100644 index db36439d..00000000 --- a/CTFd/api/v1/helpers/models.py +++ /dev/null @@ -1,12 +0,0 @@ -# This file is no longer used. If you're importing the function from here please update your imports - -from CTFd.utils.helpers.models import build_model_filters as _build_model_filters - - -def build_model_filters(model, query, field): - print("CTFd.api.v1.helpers.models.build_model_filters has been deprecated.") - print("Please switch to using CTFd.utils.helpers.models.build_model_filters") - print( - "This function will raise an exception in a future minor release of CTFd and then be removed in a major release." - ) - return _build_model_filters(model, query, field) diff --git a/CTFd/api/v1/helpers/request.py b/CTFd/api/v1/helpers/request.py deleted file mode 100644 index 186d6db0..00000000 --- a/CTFd/api/v1/helpers/request.py +++ /dev/null @@ -1,60 +0,0 @@ -from functools import wraps - -from flask import request -from pydantic import ValidationError, create_model - -ARG_LOCATIONS = { - "query": lambda: request.args, - "json": lambda: request.get_json(), - "form": lambda: request.form, - "headers": lambda: request.headers, - "cookies": lambda: request.cookies, -} - - -def validate_args(spec, location): - """ - A rough implementation of webargs using pydantic schemas. You can pass a - pydantic schema as spec or create it on the fly as follows: - - @validate_args({"name": (str, None), "id": (int, None)}, location="query") - """ - if isinstance(spec, dict): - spec = create_model("", **spec) - - schema = spec.schema() - props = schema.get("properties", {}) - required = schema.get("required", []) - - for k in props: - if k in required: - props[k]["required"] = True - props[k]["in"] = location - - def decorator(func): - # Inject parameters information into the Flask-Restx apidoc attribute. - # Not really a good solution. See https://github.com/CTFd/CTFd/issues/1504 - apidoc = getattr(func, "__apidoc__", {"params": {}}) - apidoc["params"].update(props) - func.__apidoc__ = apidoc - - @wraps(func) - def wrapper(*args, **kwargs): - data = ARG_LOCATIONS[location]() - try: - # Try to load data according to pydantic spec - loaded = spec(**data).dict(exclude_unset=True) - except ValidationError as e: - # Handle reporting errors when invalid - resp = {} - errors = e.errors() - for err in errors: - loc = err["loc"][0] - msg = err["msg"] - resp[loc] = msg - return {"success": False, "errors": resp}, 400 - return func(*args, loaded, **kwargs) - - return wrapper - - return decorator diff --git a/CTFd/api/v1/helpers/schemas.py b/CTFd/api/v1/helpers/schemas.py deleted file mode 100644 index cd3ed4f6..00000000 --- a/CTFd/api/v1/helpers/schemas.py +++ /dev/null @@ -1,37 +0,0 @@ -from typing import Container, Dict, Type - -from pydantic import BaseModel, create_model -from sqlalchemy.inspection import inspect -from sqlalchemy.orm.properties import ColumnProperty - - -def sqlalchemy_to_pydantic( - db_model: Type, *, include: Dict[str, type] = None, exclude: Container[str] = None -) -> Type[BaseModel]: - """ - Mostly copied from https://github.com/tiangolo/pydantic-sqlalchemy - """ - if exclude is None: - exclude = [] - mapper = inspect(db_model) - fields = {} - for attr in mapper.attrs: - if isinstance(attr, ColumnProperty): - if attr.columns: - column = attr.columns[0] - python_type = column.type.python_type - name = attr.key - if name in exclude: - continue - default = None - if column.default is None and not column.nullable: - default = ... - fields[name] = (python_type, default) - if bool(include): - for name, python_type in include.items(): - default = None - fields[name] = (python_type, default) - pydantic_model = create_model( - db_model.__name__, **fields # type: ignore - ) - return pydantic_model diff --git a/CTFd/api/v1/hints.py b/CTFd/api/v1/hints.py deleted file mode 100644 index 0ee60fa3..00000000 --- a/CTFd/api/v1/hints.py +++ /dev/null @@ -1,216 +0,0 @@ -from typing import List - -from flask import request -from flask_restx import Namespace, Resource - -from CTFd.api.v1.helpers.request import validate_args -from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic -from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse -from CTFd.constants import RawEnum -from CTFd.models import Hints, HintUnlocks, db -from CTFd.schemas.hints import HintSchema -from CTFd.utils.decorators import admins_only, authed_only, during_ctf_time_only -from CTFd.utils.helpers.models import build_model_filters -from CTFd.utils.user import get_current_user, is_admin - -hints_namespace = Namespace("hints", description="Endpoint to retrieve Hints") - -HintModel = sqlalchemy_to_pydantic(Hints) - - -class HintDetailedSuccessResponse(APIDetailedSuccessResponse): - data: HintModel - - -class HintListSuccessResponse(APIListSuccessResponse): - data: List[HintModel] - - -hints_namespace.schema_model( - "HintDetailedSuccessResponse", HintDetailedSuccessResponse.apidoc() -) - -hints_namespace.schema_model( - "HintListSuccessResponse", HintListSuccessResponse.apidoc() -) - - -@hints_namespace.route("") -class HintList(Resource): - @admins_only - @hints_namespace.doc( - description="Endpoint to list Hint objects in bulk", - responses={ - 200: ("Success", "HintListSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - @validate_args( - { - "type": (str, None), - "challenge_id": (int, None), - "content": (str, None), - "cost": (int, None), - "q": (str, None), - "field": ( - RawEnum("HintFields", {"type": "type", "content": "content"}), - None, - ), - }, - location="query", - ) - def get(self, query_args): - q = query_args.pop("q", None) - field = str(query_args.pop("field", None)) - filters = build_model_filters(model=Hints, query=q, field=field) - - hints = Hints.query.filter_by(**query_args).filter(*filters).all() - response = HintSchema(many=True, view="locked").dump(hints) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return {"success": True, "data": response.data} - - @admins_only - @hints_namespace.doc( - description="Endpoint to create a Hint object", - responses={ - 200: ("Success", "HintDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def post(self): - req = request.get_json() - schema = HintSchema(view="admin") - response = schema.load(req, session=db.session) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - db.session.add(response.data) - db.session.commit() - - response = schema.dump(response.data) - - return {"success": True, "data": response.data} - - -@hints_namespace.route("/") -class Hint(Resource): - @during_ctf_time_only - @authed_only - @hints_namespace.doc( - description="Endpoint to get a specific Hint object", - responses={ - 200: ("Success", "HintDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def get(self, hint_id): - user = get_current_user() - hint = Hints.query.filter_by(id=hint_id).first_or_404() - - if hint.requirements: - requirements = hint.requirements.get("prerequisites", []) - - # Get the IDs of all hints that the user has unlocked - all_unlocks = HintUnlocks.query.filter_by(account_id=user.account_id).all() - unlock_ids = {unlock.target for unlock in all_unlocks} - - # Get the IDs of all free hints - free_hints = Hints.query.filter_by(cost=0).all() - free_ids = {h.id for h in free_hints} - - # Add free hints to unlocked IDs - unlock_ids.update(free_ids) - - # Filter out hint IDs that don't exist - all_hint_ids = {h.id for h in Hints.query.with_entities(Hints.id).all()} - prereqs = set(requirements).intersection(all_hint_ids) - - # If the user has the necessary unlocks or is admin we should allow them to view - if unlock_ids >= prereqs or is_admin(): - pass - else: - return ( - { - "success": False, - "errors": { - "requirements": [ - "You must unlock other hints before accessing this hint" - ] - }, - }, - 403, - ) - - view = "unlocked" - if hint.cost: - view = "locked" - unlocked = HintUnlocks.query.filter_by( - account_id=user.account_id, target=hint.id - ).first() - if unlocked: - view = "unlocked" - - if is_admin(): - if request.args.get("preview", False): - view = "admin" - - response = HintSchema(view=view).dump(hint) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return {"success": True, "data": response.data} - - @admins_only - @hints_namespace.doc( - description="Endpoint to edit a specific Hint object", - responses={ - 200: ("Success", "HintDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def patch(self, hint_id): - hint = Hints.query.filter_by(id=hint_id).first_or_404() - req = request.get_json() - - schema = HintSchema(view="admin") - response = schema.load(req, instance=hint, partial=True, session=db.session) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - db.session.add(response.data) - db.session.commit() - - response = schema.dump(response.data) - - return {"success": True, "data": response.data} - - @admins_only - @hints_namespace.doc( - description="Endpoint to delete a specific Tag object", - responses={200: ("Success", "APISimpleSuccessResponse")}, - ) - def delete(self, hint_id): - hint = Hints.query.filter_by(id=hint_id).first_or_404() - db.session.delete(hint) - db.session.commit() - db.session.close() - - return {"success": True} diff --git a/CTFd/api/v1/notifications.py b/CTFd/api/v1/notifications.py deleted file mode 100644 index 072430e7..00000000 --- a/CTFd/api/v1/notifications.py +++ /dev/null @@ -1,189 +0,0 @@ -from typing import List - -from flask import current_app, make_response, request -from flask_restx import Namespace, Resource - -from CTFd.api.v1.helpers.request import validate_args -from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic -from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse -from CTFd.constants import RawEnum -from CTFd.models import Notifications, db -from CTFd.schemas.notifications import NotificationSchema -from CTFd.utils.decorators import admins_only -from CTFd.utils.helpers.models import build_model_filters - -notifications_namespace = Namespace( - "notifications", description="Endpoint to retrieve Notifications" -) - -NotificationModel = sqlalchemy_to_pydantic(Notifications) -TransientNotificationModel = sqlalchemy_to_pydantic(Notifications, exclude=["id"]) - - -class NotificationDetailedSuccessResponse(APIDetailedSuccessResponse): - data: NotificationModel - - -class NotificationListSuccessResponse(APIListSuccessResponse): - data: List[NotificationModel] - - -notifications_namespace.schema_model( - "NotificationDetailedSuccessResponse", NotificationDetailedSuccessResponse.apidoc() -) - -notifications_namespace.schema_model( - "NotificationListSuccessResponse", NotificationListSuccessResponse.apidoc() -) - - -@notifications_namespace.route("") -class NotificantionList(Resource): - @notifications_namespace.doc( - description="Endpoint to get notification objects in bulk", - responses={ - 200: ("Success", "NotificationListSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - @validate_args( - { - "title": (str, None), - "content": (str, None), - "user_id": (int, None), - "team_id": (int, None), - "q": (str, None), - "field": ( - RawEnum("NotificationFields", {"title": "title", "content": "content"}), - None, - ), - "since_id": (int, None), - }, - location="query", - ) - def get(self, query_args): - q = query_args.pop("q", None) - field = str(query_args.pop("field", None)) - filters = build_model_filters(model=Notifications, query=q, field=field) - - since_id = query_args.pop("since_id", None) - if since_id: - filters.append((Notifications.id > since_id)) - - notifications = ( - Notifications.query.filter_by(**query_args).filter(*filters).all() - ) - schema = NotificationSchema(many=True) - result = schema.dump(notifications) - if result.errors: - return {"success": False, "errors": result.errors}, 400 - return {"success": True, "data": result.data} - - @notifications_namespace.doc( - description="Endpoint to get statistics for notification objects in bulk", - responses={200: ("Success", "APISimpleSuccessResponse")}, - ) - @validate_args( - { - "title": (str, None), - "content": (str, None), - "user_id": (int, None), - "team_id": (int, None), - "q": (str, None), - "field": ( - RawEnum("NotificationFields", {"title": "title", "content": "content"}), - None, - ), - "since_id": (int, None), - }, - location="query", - ) - def head(self, query_args): - q = query_args.pop("q", None) - field = str(query_args.pop("field", None)) - filters = build_model_filters(model=Notifications, query=q, field=field) - - since_id = query_args.pop("since_id", None) - if since_id: - filters.append((Notifications.id > since_id)) - - notification_count = ( - Notifications.query.filter_by(**query_args).filter(*filters).count() - ) - response = make_response() - response.headers["Result-Count"] = notification_count - return response - - @admins_only - @notifications_namespace.doc( - description="Endpoint to create a notification object", - responses={ - 200: ("Success", "NotificationDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def post(self): - req = request.get_json() - - schema = NotificationSchema() - result = schema.load(req) - - if result.errors: - return {"success": False, "errors": result.errors}, 400 - - db.session.add(result.data) - db.session.commit() - - response = schema.dump(result.data) - - # Grab additional settings - notif_type = req.get("type", "alert") - notif_sound = req.get("sound", True) - response.data["type"] = notif_type - response.data["sound"] = notif_sound - - current_app.events_manager.publish(data=response.data, type="notification") - - return {"success": True, "data": response.data} - - -@notifications_namespace.route("/") -@notifications_namespace.param("notification_id", "A Notification ID") -class Notification(Resource): - @notifications_namespace.doc( - description="Endpoint to get a specific notification object", - responses={ - 200: ("Success", "NotificationDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def get(self, notification_id): - notif = Notifications.query.filter_by(id=notification_id).first_or_404() - schema = NotificationSchema() - response = schema.dump(notif) - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return {"success": True, "data": response.data} - - @admins_only - @notifications_namespace.doc( - description="Endpoint to delete a notification object", - responses={200: ("Success", "APISimpleSuccessResponse")}, - ) - def delete(self, notification_id): - notif = Notifications.query.filter_by(id=notification_id).first_or_404() - db.session.delete(notif) - db.session.commit() - db.session.close() - - return {"success": True} diff --git a/CTFd/api/v1/pages.py b/CTFd/api/v1/pages.py deleted file mode 100644 index 2c2ed505..00000000 --- a/CTFd/api/v1/pages.py +++ /dev/null @@ -1,177 +0,0 @@ -from typing import List - -from flask import request -from flask_restx import Namespace, Resource - -from CTFd.api.v1.helpers.request import validate_args -from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic -from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse -from CTFd.cache import clear_pages -from CTFd.constants import RawEnum -from CTFd.models import Pages, db -from CTFd.schemas.pages import PageSchema -from CTFd.utils.decorators import admins_only -from CTFd.utils.helpers.models import build_model_filters - -pages_namespace = Namespace("pages", description="Endpoint to retrieve Pages") - - -PageModel = sqlalchemy_to_pydantic(Pages) -TransientPageModel = sqlalchemy_to_pydantic(Pages, exclude=["id"]) - - -class PageDetailedSuccessResponse(APIDetailedSuccessResponse): - data: PageModel - - -class PageListSuccessResponse(APIListSuccessResponse): - data: List[PageModel] - - -pages_namespace.schema_model( - "PageDetailedSuccessResponse", PageDetailedSuccessResponse.apidoc() -) - -pages_namespace.schema_model( - "PageListSuccessResponse", PageListSuccessResponse.apidoc() -) - - -@pages_namespace.route("") -@pages_namespace.doc( - responses={200: "Success", 400: "An error occured processing your data"} -) -class PageList(Resource): - @admins_only - @pages_namespace.doc( - description="Endpoint to get page objects in bulk", - responses={ - 200: ("Success", "PageListSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - @validate_args( - { - "id": (int, None), - "title": (str, None), - "route": (str, None), - "draft": (bool, None), - "hidden": (bool, None), - "auth_required": (bool, None), - "q": (str, None), - "field": ( - RawEnum( - "PageFields", - {"title": "title", "route": "route", "content": "content"}, - ), - None, - ), - }, - location="query", - ) - def get(self, query_args): - q = query_args.pop("q", None) - field = str(query_args.pop("field", None)) - filters = build_model_filters(model=Pages, query=q, field=field) - - pages = Pages.query.filter_by(**query_args).filter(*filters).all() - schema = PageSchema(exclude=["content"], many=True) - response = schema.dump(pages) - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return {"success": True, "data": response.data} - - @admins_only - @pages_namespace.doc( - description="Endpoint to create a page object", - responses={ - 200: ("Success", "PageDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - @validate_args(TransientPageModel, location="json") - def post(self, json_args): - req = json_args - schema = PageSchema() - response = schema.load(req) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - db.session.add(response.data) - db.session.commit() - - response = schema.dump(response.data) - db.session.close() - - clear_pages() - - return {"success": True, "data": response.data} - - -@pages_namespace.route("/") -@pages_namespace.doc( - params={"page_id": "ID of a page object"}, - responses={ - 200: ("Success", "PageDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, -) -class PageDetail(Resource): - @admins_only - @pages_namespace.doc(description="Endpoint to read a page object") - def get(self, page_id): - page = Pages.query.filter_by(id=page_id).first_or_404() - schema = PageSchema() - response = schema.dump(page) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return {"success": True, "data": response.data} - - @admins_only - @pages_namespace.doc(description="Endpoint to edit a page object") - def patch(self, page_id): - page = Pages.query.filter_by(id=page_id).first_or_404() - req = request.get_json() - - schema = PageSchema(partial=True) - response = schema.load(req, instance=page, partial=True) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - db.session.commit() - - response = schema.dump(response.data) - db.session.close() - - clear_pages() - - return {"success": True, "data": response.data} - - @admins_only - @pages_namespace.doc( - description="Endpoint to delete a page object", - responses={200: ("Success", "APISimpleSuccessResponse")}, - ) - def delete(self, page_id): - page = Pages.query.filter_by(id=page_id).first_or_404() - db.session.delete(page) - db.session.commit() - db.session.close() - - clear_pages() - - return {"success": True} diff --git a/CTFd/api/v1/schemas/__init__.py b/CTFd/api/v1/schemas/__init__.py deleted file mode 100644 index c69f83d6..00000000 --- a/CTFd/api/v1/schemas/__init__.py +++ /dev/null @@ -1,105 +0,0 @@ -from typing import Any, Dict, List, Optional - -from pydantic import BaseModel - - -class APISimpleSuccessResponse(BaseModel): - success: bool = True - - -class APIDetailedSuccessResponse(APISimpleSuccessResponse): - data: Optional[Any] - - @classmethod - def apidoc(cls): - """ - Helper to inline references from the generated schema - """ - schema = cls.schema() - - try: - key = schema["properties"]["data"]["$ref"] - ref = key.split("/").pop() - definition = schema["definitions"][ref] - schema["properties"]["data"] = definition - del schema["definitions"][ref] - if bool(schema["definitions"]) is False: - del schema["definitions"] - except KeyError: - pass - - return schema - - -class APIListSuccessResponse(APIDetailedSuccessResponse): - data: Optional[List[Any]] - - @classmethod - def apidoc(cls): - """ - Helper to inline references from the generated schema - """ - schema = cls.schema() - - try: - key = schema["properties"]["data"]["items"]["$ref"] - ref = key.split("/").pop() - definition = schema["definitions"][ref] - schema["properties"]["data"]["items"] = definition - del schema["definitions"][ref] - if bool(schema["definitions"]) is False: - del schema["definitions"] - except KeyError: - pass - - return schema - - -class PaginatedAPIListSuccessResponse(APIListSuccessResponse): - meta: Dict[str, Any] - - @classmethod - def apidoc(cls): - """ - Helper to inline references from the generated schema - """ - schema = cls.schema() - - schema["properties"]["meta"] = { - "title": "Meta", - "type": "object", - "properties": { - "pagination": { - "title": "Pagination", - "type": "object", - "properties": { - "page": {"title": "Page", "type": "integer"}, - "next": {"title": "Next", "type": "integer"}, - "prev": {"title": "Prev", "type": "integer"}, - "pages": {"title": "Pages", "type": "integer"}, - "per_page": {"title": "Per Page", "type": "integer"}, - "total": {"title": "Total", "type": "integer"}, - }, - "required": ["page", "next", "prev", "pages", "per_page", "total"], - } - }, - "required": ["pagination"], - } - - try: - key = schema["properties"]["data"]["items"]["$ref"] - ref = key.split("/").pop() - definition = schema["definitions"][ref] - schema["properties"]["data"]["items"] = definition - del schema["definitions"][ref] - if bool(schema["definitions"]) is False: - del schema["definitions"] - except KeyError: - pass - - return schema - - -class APISimpleErrorResponse(BaseModel): - success: bool = False - errors: Optional[List[str]] diff --git a/CTFd/api/v1/scoreboard.py b/CTFd/api/v1/scoreboard.py deleted file mode 100644 index 06edd3e5..00000000 --- a/CTFd/api/v1/scoreboard.py +++ /dev/null @@ -1,143 +0,0 @@ -from collections import defaultdict - -from flask_restx import Namespace, Resource -from sqlalchemy import select - -from CTFd.cache import cache, make_cache_key -from CTFd.models import Awards, Solves, Users, db -from CTFd.utils import get_config -from CTFd.utils.dates import isoformat, unix_time_to_utc -from CTFd.utils.decorators.visibility import ( - check_account_visibility, - check_score_visibility, -) -from CTFd.utils.modes import TEAMS_MODE, generate_account_url, get_mode_as_word -from CTFd.utils.scores import get_standings, get_user_standings - -scoreboard_namespace = Namespace( - "scoreboard", description="Endpoint to retrieve scores" -) - - -@scoreboard_namespace.route("") -class ScoreboardList(Resource): - @check_account_visibility - @check_score_visibility - @cache.cached(timeout=60, key_prefix=make_cache_key) - def get(self): - standings = get_standings() - response = [] - mode = get_config("user_mode") - account_type = get_mode_as_word() - - if mode == TEAMS_MODE: - r = db.session.execute( - select( - [ - Users.id, - Users.name, - Users.oauth_id, - Users.team_id, - Users.hidden, - Users.banned, - ] - ).where(Users.team_id.isnot(None)) - ) - users = r.fetchall() - membership = defaultdict(dict) - for u in users: - if u.hidden is False and u.banned is False: - membership[u.team_id][u.id] = { - "id": u.id, - "oauth_id": u.oauth_id, - "name": u.name, - "score": 0, - } - - # Get user_standings as a dict so that we can more quickly get member scores - user_standings = get_user_standings() - for u in user_standings: - membership[u.team_id][u.user_id]["score"] = int(u.score) - - for i, x in enumerate(standings): - entry = { - "pos": i + 1, - "account_id": x.account_id, - "account_url": generate_account_url(account_id=x.account_id), - "account_type": account_type, - "oauth_id": x.oauth_id, - "name": x.name, - "score": int(x.score), - } - - if mode == TEAMS_MODE: - entry["members"] = list(membership[x.account_id].values()) - - response.append(entry) - return {"success": True, "data": response} - - -@scoreboard_namespace.route("/top/") -@scoreboard_namespace.param("count", "How many top teams to return") -class ScoreboardDetail(Resource): - @check_account_visibility - @check_score_visibility - @cache.cached(timeout=60, key_prefix=make_cache_key) - def get(self, count): - response = {} - - standings = get_standings(count=count) - - team_ids = [team.account_id for team in standings] - - solves = Solves.query.filter(Solves.account_id.in_(team_ids)) - awards = Awards.query.filter(Awards.account_id.in_(team_ids)) - - freeze = get_config("freeze") - - if freeze: - solves = solves.filter(Solves.date < unix_time_to_utc(freeze)) - awards = awards.filter(Awards.date < unix_time_to_utc(freeze)) - - solves = solves.all() - awards = awards.all() - - # Build a mapping of accounts to their solves and awards - solves_mapper = defaultdict(list) - for solve in solves: - solves_mapper[solve.account_id].append( - { - "challenge_id": solve.challenge_id, - "account_id": solve.account_id, - "team_id": solve.team_id, - "user_id": solve.user_id, - "value": solve.challenge.value, - "date": isoformat(solve.date), - } - ) - - for award in awards: - solves_mapper[award.account_id].append( - { - "challenge_id": None, - "account_id": award.account_id, - "team_id": award.team_id, - "user_id": award.user_id, - "value": award.value, - "date": isoformat(award.date), - } - ) - - # Sort all solves by date - for team_id in solves_mapper: - solves_mapper[team_id] = sorted( - solves_mapper[team_id], key=lambda k: k["date"] - ) - - for i, _team in enumerate(team_ids): - response[i + 1] = { - "id": standings[i].account_id, - "name": standings[i].name, - "solves": solves_mapper.get(standings[i].account_id, []), - } - return {"success": True, "data": response} diff --git a/CTFd/api/v1/statistics/__init__.py b/CTFd/api/v1/statistics/__init__.py deleted file mode 100644 index cec5a6ce..00000000 --- a/CTFd/api/v1/statistics/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -from flask_restx import Namespace - -statistics_namespace = Namespace( - "statistics", description="Endpoint to retrieve Statistics" -) - -# isort:imports-firstparty -from CTFd.api.v1.statistics import challenges # noqa: F401,I001 -from CTFd.api.v1.statistics import scores # noqa: F401 -from CTFd.api.v1.statistics import submissions # noqa: F401 -from CTFd.api.v1.statistics import teams # noqa: F401 -from CTFd.api.v1.statistics import users # noqa: F401 diff --git a/CTFd/api/v1/statistics/challenges.py b/CTFd/api/v1/statistics/challenges.py deleted file mode 100644 index 1d762937..00000000 --- a/CTFd/api/v1/statistics/challenges.py +++ /dev/null @@ -1,120 +0,0 @@ -from flask_restx import Resource -from sqlalchemy import func -from sqlalchemy.sql import and_ - -from CTFd.api.v1.statistics import statistics_namespace -from CTFd.models import Challenges, Solves, db -from CTFd.utils.decorators import admins_only -from CTFd.utils.modes import get_model - - -@statistics_namespace.route("/challenges/") -class ChallengePropertyCounts(Resource): - @admins_only - def get(self, column): - if column in Challenges.__table__.columns.keys(): - prop = getattr(Challenges, column) - data = ( - Challenges.query.with_entities(prop, func.count(prop)) - .group_by(prop) - .all() - ) - return {"success": True, "data": dict(data)} - else: - response = {"message": "That could not be found"}, 404 - return response - - -@statistics_namespace.route("/challenges/solves") -class ChallengeSolveStatistics(Resource): - @admins_only - def get(self): - chals = ( - Challenges.query.filter( - and_(Challenges.state != "hidden", Challenges.state != "locked") - ) - .order_by(Challenges.value) - .all() - ) - - Model = get_model() - - solves_sub = ( - db.session.query( - Solves.challenge_id, db.func.count(Solves.challenge_id).label("solves") - ) - .join(Model, Solves.account_id == Model.id) - .filter(Model.banned == False, Model.hidden == False) - .group_by(Solves.challenge_id) - .subquery() - ) - - solves = ( - db.session.query( - solves_sub.columns.challenge_id, - solves_sub.columns.solves, - Challenges.name, - ) - .join(Challenges, solves_sub.columns.challenge_id == Challenges.id) - .all() - ) - - response = [] - has_solves = [] - - for challenge_id, count, name in solves: - challenge = {"id": challenge_id, "name": name, "solves": count} - response.append(challenge) - has_solves.append(challenge_id) - for c in chals: - if c.id not in has_solves: - challenge = {"id": c.id, "name": c.name, "solves": 0} - response.append(challenge) - - db.session.close() - return {"success": True, "data": response} - - -@statistics_namespace.route("/challenges/solves/percentages") -class ChallengeSolvePercentages(Resource): - @admins_only - def get(self): - challenges = ( - Challenges.query.add_columns("id", "name", "state", "max_attempts") - .order_by(Challenges.value) - .all() - ) - - Model = get_model() - - teams_with_points = ( - db.session.query(Solves.account_id) - .join(Model) - .filter(Model.banned == False, Model.hidden == False) - .group_by(Solves.account_id) - .count() - ) - - percentage_data = [] - for challenge in challenges: - solve_count = ( - Solves.query.join(Model, Solves.account_id == Model.id) - .filter( - Solves.challenge_id == challenge.id, - Model.banned == False, - Model.hidden == False, - ) - .count() - ) - - if teams_with_points > 0: - percentage = float(solve_count) / float(teams_with_points) - else: - percentage = 0.0 - - percentage_data.append( - {"id": challenge.id, "name": challenge.name, "percentage": percentage} - ) - - response = sorted(percentage_data, key=lambda x: x["percentage"], reverse=True) - return {"success": True, "data": response} diff --git a/CTFd/api/v1/statistics/scores.py b/CTFd/api/v1/statistics/scores.py deleted file mode 100644 index 32e5d8cf..00000000 --- a/CTFd/api/v1/statistics/scores.py +++ /dev/null @@ -1,43 +0,0 @@ -from collections import defaultdict - -from flask_restx import Resource - -from CTFd.api.v1.statistics import statistics_namespace -from CTFd.models import Challenges, db -from CTFd.utils.decorators import admins_only -from CTFd.utils.scores import get_standings - - -@statistics_namespace.route("/scores/distribution") -class ScoresDistribution(Resource): - @admins_only - def get(self): - challenge_count = Challenges.query.count() or 1 - total_points = ( - Challenges.query.with_entities(db.func.sum(Challenges.value).label("sum")) - .filter_by(state="visible") - .first() - .sum - ) or 0 - # Convert Decimal() to int in some database backends for Python 2 - total_points = int(total_points) - - # Divide score by challenges to get brackets with explicit floor division - bracket_size = total_points // challenge_count - - # Get standings - standings = get_standings(admin=True) - - # Iterate over standings and increment the count for each bracket for each standing within that bracket - bottom, top = 0, bracket_size - count = 1 - brackets = defaultdict(lambda: 0) - for t in reversed(standings): - if ((t.score >= bottom) and (t.score <= top)) or t.score <= 0: - brackets[top] += 1 - else: - count += 1 - bottom, top = (bracket_size, (bracket_size * count)) - brackets[top] += 1 - - return {"success": True, "data": {"brackets": brackets}} diff --git a/CTFd/api/v1/statistics/submissions.py b/CTFd/api/v1/statistics/submissions.py deleted file mode 100644 index e1c62ba1..00000000 --- a/CTFd/api/v1/statistics/submissions.py +++ /dev/null @@ -1,23 +0,0 @@ -from flask_restx import Resource -from sqlalchemy import func - -from CTFd.api.v1.statistics import statistics_namespace -from CTFd.models import Submissions -from CTFd.utils.decorators import admins_only - - -@statistics_namespace.route("/submissions/") -class SubmissionPropertyCounts(Resource): - @admins_only - def get(self, column): - if column in Submissions.__table__.columns.keys(): - prop = getattr(Submissions, column) - data = ( - Submissions.query.with_entities(prop, func.count(prop)) - .group_by(prop) - .all() - ) - return {"success": True, "data": dict(data)} - else: - response = {"success": False, "errors": "That could not be found"}, 404 - return response diff --git a/CTFd/api/v1/statistics/teams.py b/CTFd/api/v1/statistics/teams.py deleted file mode 100644 index 4bfbbf29..00000000 --- a/CTFd/api/v1/statistics/teams.py +++ /dev/null @@ -1,14 +0,0 @@ -from flask_restx import Resource - -from CTFd.api.v1.statistics import statistics_namespace -from CTFd.models import Teams -from CTFd.utils.decorators import admins_only - - -@statistics_namespace.route("/teams") -class TeamStatistics(Resource): - @admins_only - def get(self): - registered = Teams.query.count() - data = {"registered": registered} - return {"success": True, "data": data} diff --git a/CTFd/api/v1/statistics/users.py b/CTFd/api/v1/statistics/users.py deleted file mode 100644 index 881d9641..00000000 --- a/CTFd/api/v1/statistics/users.py +++ /dev/null @@ -1,30 +0,0 @@ -from flask_restx import Resource -from sqlalchemy import func - -from CTFd.api.v1.statistics import statistics_namespace -from CTFd.models import Users -from CTFd.utils.decorators import admins_only - - -@statistics_namespace.route("/users") -class UserStatistics(Resource): - @admins_only - def get(self): - registered = Users.query.count() - confirmed = Users.query.filter_by(verified=True).count() - data = {"registered": registered, "confirmed": confirmed} - return {"success": True, "data": data} - - -@statistics_namespace.route("/users/") -class UserPropertyCounts(Resource): - @admins_only - def get(self, column): - if column in Users.__table__.columns.keys(): - prop = getattr(Users, column) - data = ( - Users.query.with_entities(prop, func.count(prop)).group_by(prop).all() - ) - return {"success": True, "data": dict(data)} - else: - return {"success": False, "message": "That could not be found"}, 404 diff --git a/CTFd/api/v1/submissions.py b/CTFd/api/v1/submissions.py deleted file mode 100644 index a5494d30..00000000 --- a/CTFd/api/v1/submissions.py +++ /dev/null @@ -1,195 +0,0 @@ -from typing import List - -from flask_restx import Namespace, Resource - -from CTFd.api.v1.helpers.request import validate_args -from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic -from CTFd.api.v1.schemas import ( - APIDetailedSuccessResponse, - PaginatedAPIListSuccessResponse, -) -from CTFd.cache import clear_challenges, clear_standings -from CTFd.constants import RawEnum -from CTFd.models import Submissions, db -from CTFd.schemas.submissions import SubmissionSchema -from CTFd.utils.decorators import admins_only -from CTFd.utils.helpers.models import build_model_filters - -submissions_namespace = Namespace( - "submissions", description="Endpoint to retrieve Submission" -) - -SubmissionModel = sqlalchemy_to_pydantic(Submissions) -TransientSubmissionModel = sqlalchemy_to_pydantic(Submissions, exclude=["id"]) - - -class SubmissionDetailedSuccessResponse(APIDetailedSuccessResponse): - data: SubmissionModel - - -class SubmissionListSuccessResponse(PaginatedAPIListSuccessResponse): - data: List[SubmissionModel] - - -submissions_namespace.schema_model( - "SubmissionDetailedSuccessResponse", SubmissionDetailedSuccessResponse.apidoc() -) - -submissions_namespace.schema_model( - "SubmissionListSuccessResponse", SubmissionListSuccessResponse.apidoc() -) - - -@submissions_namespace.route("") -class SubmissionsList(Resource): - @admins_only - @submissions_namespace.doc( - description="Endpoint to get submission objects in bulk", - responses={ - 200: ("Success", "SubmissionListSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - @validate_args( - { - "challenge_id": (int, None), - "user_id": (int, None), - "team_id": (int, None), - "ip": (str, None), - "provided": (str, None), - "type": (str, None), - "q": (str, None), - "field": ( - RawEnum( - "SubmissionFields", - { - "challenge_id": "challenge_id", - "user_id": "user_id", - "team_id": "team_id", - "ip": "ip", - "provided": "provided", - "type": "type", - }, - ), - None, - ), - }, - location="query", - ) - def get(self, query_args): - q = query_args.pop("q", None) - field = str(query_args.pop("field", None)) - filters = build_model_filters(model=Submissions, query=q, field=field) - - args = query_args - schema = SubmissionSchema(many=True) - - submissions = ( - Submissions.query.filter_by(**args) - .filter(*filters) - .paginate(max_per_page=100) - ) - - response = schema.dump(submissions.items) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return { - "meta": { - "pagination": { - "page": submissions.page, - "next": submissions.next_num, - "prev": submissions.prev_num, - "pages": submissions.pages, - "per_page": submissions.per_page, - "total": submissions.total, - } - }, - "success": True, - "data": response.data, - } - - @admins_only - @submissions_namespace.doc( - description="Endpoint to create a submission object. Users should interact with the attempt endpoint to submit flags.", - responses={ - 200: ("Success", "SubmissionListSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - @validate_args(TransientSubmissionModel, location="json") - def post(self, json_args): - req = json_args - Model = Submissions.get_child(type=req.get("type")) - schema = SubmissionSchema(instance=Model()) - response = schema.load(req) - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - db.session.add(response.data) - db.session.commit() - - response = schema.dump(response.data) - db.session.close() - - # Delete standings cache - clear_standings() - # Delete challenges cache - clear_challenges() - - return {"success": True, "data": response.data} - - -@submissions_namespace.route("/") -@submissions_namespace.param("submission_id", "A Submission ID") -class Submission(Resource): - @admins_only - @submissions_namespace.doc( - description="Endpoint to get submission objects in bulk", - responses={ - 200: ("Success", "SubmissionDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def get(self, submission_id): - submission = Submissions.query.filter_by(id=submission_id).first_or_404() - schema = SubmissionSchema() - response = schema.dump(submission) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return {"success": True, "data": response.data} - - @admins_only - @submissions_namespace.doc( - description="Endpoint to get submission objects in bulk", - responses={ - 200: ("Success", "APISimpleSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def delete(self, submission_id): - submission = Submissions.query.filter_by(id=submission_id).first_or_404() - db.session.delete(submission) - db.session.commit() - db.session.close() - - # Delete standings cache - clear_standings() - clear_challenges() - - return {"success": True} diff --git a/CTFd/api/v1/tags.py b/CTFd/api/v1/tags.py deleted file mode 100644 index 84ae9228..00000000 --- a/CTFd/api/v1/tags.py +++ /dev/null @@ -1,166 +0,0 @@ -from typing import List - -from flask import request -from flask_restx import Namespace, Resource - -from CTFd.api.v1.helpers.request import validate_args -from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic -from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse -from CTFd.constants import RawEnum -from CTFd.models import Tags, db -from CTFd.schemas.tags import TagSchema -from CTFd.utils.decorators import admins_only -from CTFd.utils.helpers.models import build_model_filters - -tags_namespace = Namespace("tags", description="Endpoint to retrieve Tags") - -TagModel = sqlalchemy_to_pydantic(Tags) - - -class TagDetailedSuccessResponse(APIDetailedSuccessResponse): - data: TagModel - - -class TagListSuccessResponse(APIListSuccessResponse): - data: List[TagModel] - - -tags_namespace.schema_model( - "TagDetailedSuccessResponse", TagDetailedSuccessResponse.apidoc() -) - -tags_namespace.schema_model("TagListSuccessResponse", TagListSuccessResponse.apidoc()) - - -@tags_namespace.route("") -class TagList(Resource): - @admins_only - @tags_namespace.doc( - description="Endpoint to list Tag objects in bulk", - responses={ - 200: ("Success", "TagListSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - @validate_args( - { - "challenge_id": (int, None), - "value": (str, None), - "q": (str, None), - "field": ( - RawEnum( - "TagFields", {"challenge_id": "challenge_id", "value": "value"} - ), - None, - ), - }, - location="query", - ) - def get(self, query_args): - q = query_args.pop("q", None) - field = str(query_args.pop("field", None)) - filters = build_model_filters(model=Tags, query=q, field=field) - - tags = Tags.query.filter_by(**query_args).filter(*filters).all() - schema = TagSchema(many=True) - response = schema.dump(tags) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return {"success": True, "data": response.data} - - @admins_only - @tags_namespace.doc( - description="Endpoint to create a Tag object", - responses={ - 200: ("Success", "TagDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def post(self): - req = request.get_json() - schema = TagSchema() - response = schema.load(req, session=db.session) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - db.session.add(response.data) - db.session.commit() - - response = schema.dump(response.data) - db.session.close() - - return {"success": True, "data": response.data} - - -@tags_namespace.route("/") -@tags_namespace.param("tag_id", "A Tag ID") -class Tag(Resource): - @admins_only - @tags_namespace.doc( - description="Endpoint to get a specific Tag object", - responses={ - 200: ("Success", "TagDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def get(self, tag_id): - tag = Tags.query.filter_by(id=tag_id).first_or_404() - - response = TagSchema().dump(tag) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return {"success": True, "data": response.data} - - @admins_only - @tags_namespace.doc( - description="Endpoint to edit a specific Tag object", - responses={ - 200: ("Success", "TagDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def patch(self, tag_id): - tag = Tags.query.filter_by(id=tag_id).first_or_404() - schema = TagSchema() - req = request.get_json() - - response = schema.load(req, session=db.session, instance=tag) - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - db.session.commit() - - response = schema.dump(response.data) - db.session.close() - - return {"success": True, "data": response.data} - - @admins_only - @tags_namespace.doc( - description="Endpoint to delete a specific Tag object", - responses={200: ("Success", "APISimpleSuccessResponse")}, - ) - def delete(self, tag_id): - tag = Tags.query.filter_by(id=tag_id).first_or_404() - db.session.delete(tag) - db.session.commit() - db.session.close() - - return {"success": True} diff --git a/CTFd/api/v1/teams.py b/CTFd/api/v1/teams.py deleted file mode 100644 index a3a2ea37..00000000 --- a/CTFd/api/v1/teams.py +++ /dev/null @@ -1,657 +0,0 @@ -import copy -from typing import List - -from flask import abort, request, session -from flask_restx import Namespace, Resource - -from CTFd.api.v1.helpers.request import validate_args -from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic -from CTFd.api.v1.schemas import ( - APIDetailedSuccessResponse, - PaginatedAPIListSuccessResponse, -) -from CTFd.cache import ( - clear_challenges, - clear_standings, - clear_team_session, - clear_user_session, -) -from CTFd.constants import RawEnum -from CTFd.models import Awards, Submissions, Teams, Unlocks, Users, db -from CTFd.schemas.awards import AwardSchema -from CTFd.schemas.submissions import SubmissionSchema -from CTFd.schemas.teams import TeamSchema -from CTFd.utils import get_config -from CTFd.utils.decorators import admins_only, authed_only, require_team -from CTFd.utils.decorators.modes import require_team_mode -from CTFd.utils.decorators.visibility import ( - check_account_visibility, - check_score_visibility, -) -from CTFd.utils.helpers.models import build_model_filters -from CTFd.utils.user import get_current_team, get_current_user_type, is_admin - -teams_namespace = Namespace("teams", description="Endpoint to retrieve Teams") - -TeamModel = sqlalchemy_to_pydantic(Teams) -TransientTeamModel = sqlalchemy_to_pydantic(Teams, exclude=["id"]) - - -class TeamDetailedSuccessResponse(APIDetailedSuccessResponse): - data: TeamModel - - -class TeamListSuccessResponse(PaginatedAPIListSuccessResponse): - data: List[TeamModel] - - -teams_namespace.schema_model( - "TeamDetailedSuccessResponse", TeamDetailedSuccessResponse.apidoc() -) - -teams_namespace.schema_model( - "TeamListSuccessResponse", TeamListSuccessResponse.apidoc() -) - - -@teams_namespace.route("") -class TeamList(Resource): - method_decorators = [require_team_mode] - - @check_account_visibility - @teams_namespace.doc( - description="Endpoint to get Team objects in bulk", - responses={ - 200: ("Success", "TeamListSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - @validate_args( - { - "affiliation": (str, None), - "country": (str, None), - "bracket": (str, None), - "q": (str, None), - "field": ( - RawEnum( - "TeamFields", - { - "name": "name", - "website": "website", - "country": "country", - "bracket": "bracket", - "affiliation": "affiliation", - }, - ), - None, - ), - }, - location="query", - ) - def get(self, query_args): - q = query_args.pop("q", None) - field = str(query_args.pop("field", None)) - filters = build_model_filters(model=Teams, query=q, field=field) - - if is_admin() and request.args.get("view") == "admin": - teams = ( - Teams.query.filter_by(**query_args) - .filter(*filters) - .paginate(per_page=50, max_per_page=100) - ) - else: - teams = ( - Teams.query.filter_by(hidden=False, banned=False, **query_args) - .filter(*filters) - .paginate(per_page=50, max_per_page=100) - ) - - user_type = get_current_user_type(fallback="user") - view = copy.deepcopy(TeamSchema.views.get(user_type)) - view.remove("members") - response = TeamSchema(view=view, many=True).dump(teams.items) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return { - "meta": { - "pagination": { - "page": teams.page, - "next": teams.next_num, - "prev": teams.prev_num, - "pages": teams.pages, - "per_page": teams.per_page, - "total": teams.total, - } - }, - "success": True, - "data": response.data, - } - - @admins_only - @teams_namespace.doc( - description="Endpoint to create a Team object", - responses={ - 200: ("Success", "TeamDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def post(self): - req = request.get_json() - user_type = get_current_user_type() - view = TeamSchema.views.get(user_type) - schema = TeamSchema(view=view) - response = schema.load(req) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - db.session.add(response.data) - db.session.commit() - - response = schema.dump(response.data) - db.session.close() - - clear_standings() - clear_challenges() - - return {"success": True, "data": response.data} - - -@teams_namespace.route("/") -@teams_namespace.param("team_id", "Team ID") -class TeamPublic(Resource): - method_decorators = [require_team_mode] - - @check_account_visibility - @teams_namespace.doc( - description="Endpoint to get a specific Team object", - responses={ - 200: ("Success", "TeamDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def get(self, team_id): - team = Teams.query.filter_by(id=team_id).first_or_404() - - if (team.banned or team.hidden) and is_admin() is False: - abort(404) - - user_type = get_current_user_type(fallback="user") - view = TeamSchema.views.get(user_type) - schema = TeamSchema(view=view) - response = schema.dump(team) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - response.data["place"] = team.place - response.data["score"] = team.score - return {"success": True, "data": response.data} - - @admins_only - @teams_namespace.doc( - description="Endpoint to edit a specific Team object", - responses={ - 200: ("Success", "TeamDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def patch(self, team_id): - team = Teams.query.filter_by(id=team_id).first_or_404() - data = request.get_json() - data["id"] = team_id - - schema = TeamSchema(view="admin", instance=team, partial=True) - - response = schema.load(data) - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - response = schema.dump(response.data) - db.session.commit() - - clear_team_session(team_id=team.id) - clear_standings() - clear_challenges() - - db.session.close() - - return {"success": True, "data": response.data} - - @admins_only - @teams_namespace.doc( - description="Endpoint to delete a specific Team object", - responses={200: ("Success", "APISimpleSuccessResponse")}, - ) - def delete(self, team_id): - team = Teams.query.filter_by(id=team_id).first_or_404() - team_id = team.id - - for member in team.members: - member.team_id = None - clear_user_session(user_id=member.id) - - db.session.delete(team) - db.session.commit() - - clear_team_session(team_id=team_id) - clear_standings() - clear_challenges() - - db.session.close() - - return {"success": True} - - -@teams_namespace.route("/me") -@teams_namespace.param("team_id", "Current Team") -class TeamPrivate(Resource): - method_decorators = [require_team_mode] - - @authed_only - @require_team - @teams_namespace.doc( - description="Endpoint to get the current user's Team object", - responses={ - 200: ("Success", "TeamDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def get(self): - team = get_current_team() - response = TeamSchema(view="self").dump(team) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - response.data["place"] = team.place - response.data["score"] = team.score - return {"success": True, "data": response.data} - - @authed_only - @require_team - @teams_namespace.doc( - description="Endpoint to edit the current user's Team object", - responses={ - 200: ("Success", "TeamDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def patch(self): - team = get_current_team() - if team.captain_id != session["id"]: - return ( - { - "success": False, - "errors": {"": ["Only team captains can edit team information"]}, - }, - 403, - ) - - data = request.get_json() - - response = TeamSchema(view="self", instance=team, partial=True).load(data) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - db.session.commit() - clear_team_session(team_id=team.id) - response = TeamSchema("self").dump(response.data) - db.session.close() - - return {"success": True, "data": response.data} - - @authed_only - @require_team - @teams_namespace.doc( - description="Endpoint to disband your current team. Can only be used if the team has performed no actions in the CTF.", - responses={200: ("Success", "APISimpleSuccessResponse")}, - ) - def delete(self): - team_disbanding = get_config("team_disbanding", default="inactive_only") - if team_disbanding == "disabled": - return ( - { - "success": False, - "errors": {"": ["Team disbanding is currently disabled"]}, - }, - 403, - ) - - team = get_current_team() - if team.captain_id != session["id"]: - return ( - { - "success": False, - "errors": {"": ["Only team captains can disband their team"]}, - }, - 403, - ) - - # The team must not have performed any actions in the CTF - performed_actions = any( - [ - team.solves != [], - team.fails != [], - team.awards != [], - Submissions.query.filter_by(team_id=team.id).all() != [], - Unlocks.query.filter_by(team_id=team.id).all() != [], - ] - ) - - if performed_actions: - return ( - { - "success": False, - "errors": { - "": [ - "You cannot disband your team as it has participated in the event. " - "Please contact an admin to disband your team or remove a member." - ] - }, - }, - 403, - ) - - for member in team.members: - member.team_id = None - clear_user_session(user_id=member.id) - - db.session.delete(team) - db.session.commit() - - clear_team_session(team_id=team.id) - clear_standings() - clear_challenges() - - db.session.close() - - return {"success": True} - - -@teams_namespace.route("/me/members") -class TeamPrivateMembers(Resource): - method_decorators = [require_team_mode] - - @authed_only - @require_team - def post(self): - team = get_current_team() - if team.captain_id != session["id"]: - return ( - { - "success": False, - "errors": {"": ["Only team captains can generate invite codes"]}, - }, - 403, - ) - - invite_code = team.get_invite_code() - response = {"code": invite_code} - return {"success": True, "data": response} - - -@teams_namespace.route("//members") -@teams_namespace.param("team_id", "Team ID") -class TeamMembers(Resource): - method_decorators = [require_team_mode] - - @admins_only - def get(self, team_id): - team = Teams.query.filter_by(id=team_id).first_or_404() - - view = "admin" if is_admin() else "user" - schema = TeamSchema(view=view) - response = schema.dump(team) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - members = response.data.get("members") - - return {"success": True, "data": members} - - @admins_only - def post(self, team_id): - team = Teams.query.filter_by(id=team_id).first_or_404() - - # Generate an invite code if no user or body is specified - if len(request.data) == 0: - invite_code = team.get_invite_code() - response = {"code": invite_code} - return {"success": True, "data": response} - - data = request.get_json() - user_id = data.get("user_id") - user = Users.query.filter_by(id=user_id).first_or_404() - if user.team_id is None: - team.members.append(user) - db.session.commit() - else: - return ( - { - "success": False, - "errors": {"id": ["User has already joined a team"]}, - }, - 400, - ) - - view = "admin" if is_admin() else "user" - schema = TeamSchema(view=view) - response = schema.dump(team) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - members = response.data.get("members") - - return {"success": True, "data": members} - - @admins_only - def delete(self, team_id): - team = Teams.query.filter_by(id=team_id).first_or_404() - - data = request.get_json() - user_id = data["user_id"] - user = Users.query.filter_by(id=user_id).first_or_404() - - if user.team_id == team.id: - team.members.remove(user) - - # Remove information that links the user to the team - Submissions.query.filter_by(user_id=user.id).delete() - Awards.query.filter_by(user_id=user.id).delete() - Unlocks.query.filter_by(user_id=user.id).delete() - - db.session.commit() - else: - return ( - {"success": False, "errors": {"id": ["User is not part of this team"]}}, - 400, - ) - - view = "admin" if is_admin() else "user" - schema = TeamSchema(view=view) - response = schema.dump(team) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - members = response.data.get("members") - - return {"success": True, "data": members} - - -@teams_namespace.route("/me/solves") -class TeamPrivateSolves(Resource): - method_decorators = [require_team_mode] - - @authed_only - @require_team - def get(self): - team = get_current_team() - solves = team.get_solves(admin=True) - - view = "admin" if is_admin() else "user" - schema = SubmissionSchema(view=view, many=True) - response = schema.dump(solves) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - count = len(response.data) - return {"success": True, "data": response.data, "meta": {"count": count}} - - -@teams_namespace.route("/me/fails") -class TeamPrivateFails(Resource): - method_decorators = [require_team_mode] - - @authed_only - @require_team - def get(self): - team = get_current_team() - fails = team.get_fails(admin=True) - - view = "admin" if is_admin() else "user" - - # We want to return the count purely for stats & graphs - # but this data isn't really needed by the end user. - # Only actually show fail data for admins. - if is_admin(): - schema = SubmissionSchema(view=view, many=True) - response = schema.dump(fails) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - data = response.data - else: - data = [] - count = len(fails) - - return {"success": True, "data": data, "meta": {"count": count}} - - -@teams_namespace.route("/me/awards") -class TeamPrivateAwards(Resource): - method_decorators = [require_team_mode] - - @authed_only - @require_team - def get(self): - team = get_current_team() - awards = team.get_awards(admin=True) - - schema = AwardSchema(many=True) - response = schema.dump(awards) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - count = len(response.data) - return {"success": True, "data": response.data, "meta": {"count": count}} - - -@teams_namespace.route("//solves") -@teams_namespace.param("team_id", "Team ID") -class TeamPublicSolves(Resource): - method_decorators = [require_team_mode] - - @check_account_visibility - @check_score_visibility - def get(self, team_id): - team = Teams.query.filter_by(id=team_id).first_or_404() - - if (team.banned or team.hidden) and is_admin() is False: - abort(404) - solves = team.get_solves(admin=is_admin()) - - view = "admin" if is_admin() else "user" - schema = SubmissionSchema(view=view, many=True) - response = schema.dump(solves) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - count = len(response.data) - return {"success": True, "data": response.data, "meta": {"count": count}} - - -@teams_namespace.route("//fails") -@teams_namespace.param("team_id", "Team ID") -class TeamPublicFails(Resource): - method_decorators = [require_team_mode] - - @check_account_visibility - @check_score_visibility - def get(self, team_id): - team = Teams.query.filter_by(id=team_id).first_or_404() - - if (team.banned or team.hidden) and is_admin() is False: - abort(404) - fails = team.get_fails(admin=is_admin()) - - view = "admin" if is_admin() else "user" - - # We want to return the count purely for stats & graphs - # but this data isn't really needed by the end user. - # Only actually show fail data for admins. - if is_admin(): - schema = SubmissionSchema(view=view, many=True) - response = schema.dump(fails) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - data = response.data - else: - data = [] - count = len(fails) - - return {"success": True, "data": data, "meta": {"count": count}} - - -@teams_namespace.route("//awards") -@teams_namespace.param("team_id", "Team ID") -class TeamPublicAwards(Resource): - method_decorators = [require_team_mode] - - @check_account_visibility - @check_score_visibility - def get(self, team_id): - team = Teams.query.filter_by(id=team_id).first_or_404() - - if (team.banned or team.hidden) and is_admin() is False: - abort(404) - awards = team.get_awards(admin=is_admin()) - - schema = AwardSchema(many=True) - response = schema.dump(awards) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - count = len(response.data) - return {"success": True, "data": response.data, "meta": {"count": count}} diff --git a/CTFd/api/v1/tokens.py b/CTFd/api/v1/tokens.py deleted file mode 100644 index 2c2011a4..00000000 --- a/CTFd/api/v1/tokens.py +++ /dev/null @@ -1,152 +0,0 @@ -import datetime -from typing import List - -from flask import request, session -from flask_restx import Namespace, Resource - -from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic -from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse -from CTFd.models import Tokens, db -from CTFd.schemas.tokens import TokenSchema -from CTFd.utils.decorators import authed_only, require_verified_emails -from CTFd.utils.security.auth import generate_user_token -from CTFd.utils.user import get_current_user, get_current_user_type, is_admin - -tokens_namespace = Namespace("tokens", description="Endpoint to retrieve Tokens") - -TokenModel = sqlalchemy_to_pydantic(Tokens) -ValuelessTokenModel = sqlalchemy_to_pydantic(Tokens, exclude=["value"]) - - -class TokenDetailedSuccessResponse(APIDetailedSuccessResponse): - data: TokenModel - - -class ValuelessTokenDetailedSuccessResponse(APIDetailedSuccessResponse): - data: ValuelessTokenModel - - -class TokenListSuccessResponse(APIListSuccessResponse): - data: List[TokenModel] - - -tokens_namespace.schema_model( - "TokenDetailedSuccessResponse", TokenDetailedSuccessResponse.apidoc() -) - -tokens_namespace.schema_model( - "ValuelessTokenDetailedSuccessResponse", - ValuelessTokenDetailedSuccessResponse.apidoc(), -) - -tokens_namespace.schema_model( - "TokenListSuccessResponse", TokenListSuccessResponse.apidoc() -) - - -@tokens_namespace.route("") -class TokenList(Resource): - @require_verified_emails - @authed_only - @tokens_namespace.doc( - description="Endpoint to get token objects in bulk", - responses={ - 200: ("Success", "TokenListSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def get(self): - user = get_current_user() - tokens = Tokens.query.filter_by(user_id=user.id) - response = TokenSchema(view=["id", "type", "expiration"], many=True).dump( - tokens - ) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return {"success": True, "data": response.data} - - @require_verified_emails - @authed_only - @tokens_namespace.doc( - description="Endpoint to create a token object", - responses={ - 200: ("Success", "TokenDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def post(self): - req = request.get_json() - expiration = req.get("expiration") - if expiration: - expiration = datetime.datetime.strptime(expiration, "%Y-%m-%d") - - user = get_current_user() - token = generate_user_token(user, expiration=expiration) - - # Explicitly use admin view so that user's can see the value of their token - schema = TokenSchema(view="admin") - response = schema.dump(token) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return {"success": True, "data": response.data} - - -@tokens_namespace.route("/") -@tokens_namespace.param("token_id", "A Token ID") -class TokenDetail(Resource): - @require_verified_emails - @authed_only - @tokens_namespace.doc( - description="Endpoint to get an existing token object", - responses={ - 200: ("Success", "ValuelessTokenDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def get(self, token_id): - if is_admin(): - token = Tokens.query.filter_by(id=token_id).first_or_404() - else: - token = Tokens.query.filter_by( - id=token_id, user_id=session["id"] - ).first_or_404() - - user_type = get_current_user_type(fallback="user") - schema = TokenSchema(view=user_type) - response = schema.dump(token) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return {"success": True, "data": response.data} - - @require_verified_emails - @authed_only - @tokens_namespace.doc( - description="Endpoint to delete an existing token object", - responses={200: ("Success", "APISimpleSuccessResponse")}, - ) - def delete(self, token_id): - if is_admin(): - token = Tokens.query.filter_by(id=token_id).first_or_404() - else: - user = get_current_user() - token = Tokens.query.filter_by(id=token_id, user_id=user.id).first_or_404() - db.session.delete(token) - db.session.commit() - db.session.close() - - return {"success": True} diff --git a/CTFd/api/v1/topics.py b/CTFd/api/v1/topics.py deleted file mode 100644 index ef50389b..00000000 --- a/CTFd/api/v1/topics.py +++ /dev/null @@ -1,177 +0,0 @@ -from typing import List - -from flask import request -from flask_restx import Namespace, Resource - -from CTFd.api.v1.helpers.request import validate_args -from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic -from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse -from CTFd.constants import RawEnum -from CTFd.models import ChallengeTopics, Topics, db -from CTFd.schemas.topics import ChallengeTopicSchema, TopicSchema -from CTFd.utils.decorators import admins_only -from CTFd.utils.helpers.models import build_model_filters - -topics_namespace = Namespace("topics", description="Endpoint to retrieve Topics") - -TopicModel = sqlalchemy_to_pydantic(Topics) - - -class TopicDetailedSuccessResponse(APIDetailedSuccessResponse): - data: TopicModel - - -class TopicListSuccessResponse(APIListSuccessResponse): - data: List[TopicModel] - - -topics_namespace.schema_model( - "TopicDetailedSuccessResponse", TopicDetailedSuccessResponse.apidoc() -) - -topics_namespace.schema_model( - "TopicListSuccessResponse", TopicListSuccessResponse.apidoc() -) - - -@topics_namespace.route("") -class TopicList(Resource): - @admins_only - @topics_namespace.doc( - description="Endpoint to list Topic objects in bulk", - responses={ - 200: ("Success", "TopicListSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - @validate_args( - { - "value": (str, None), - "q": (str, None), - "field": (RawEnum("TopicFields", {"value": "value"}), None,), - }, - location="query", - ) - def get(self, query_args): - q = query_args.pop("q", None) - field = str(query_args.pop("field", None)) - filters = build_model_filters(model=Topics, query=q, field=field) - - topics = Topics.query.filter_by(**query_args).filter(*filters).all() - schema = TopicSchema(many=True) - response = schema.dump(topics) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return {"success": True, "data": response.data} - - @admins_only - @topics_namespace.doc( - description="Endpoint to create a Topic object", - responses={ - 200: ("Success", "TopicDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def post(self): - req = request.get_json() - value = req.get("value") - - if value: - topic = Topics.query.filter_by(value=value).first() - if topic is None: - schema = TopicSchema() - response = schema.load(req, session=db.session) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - topic = response.data - db.session.add(topic) - db.session.commit() - else: - topic_id = req.get("topic_id") - topic = Topics.query.filter_by(id=topic_id).first_or_404() - - req["topic_id"] = topic.id - topic_type = req.get("type") - if topic_type == "challenge": - schema = ChallengeTopicSchema() - response = schema.load(req, session=db.session) - else: - return {"success": False}, 400 - - db.session.add(response.data) - db.session.commit() - - response = schema.dump(response.data) - db.session.close() - - return {"success": True, "data": response.data} - - @admins_only - @topics_namespace.doc( - description="Endpoint to delete a specific Topic object of a specific type", - responses={200: ("Success", "APISimpleSuccessResponse")}, - ) - @validate_args( - {"type": (str, None), "target_id": (int, 0)}, location="query", - ) - def delete(self, query_args): - topic_type = query_args.get("type") - target_id = int(query_args.get("target_id", 0)) - - if topic_type == "challenge": - Model = ChallengeTopics - else: - return {"success": False}, 400 - - topic = Model.query.filter_by(id=target_id).first_or_404() - db.session.delete(topic) - db.session.commit() - db.session.close() - - return {"success": True} - - -@topics_namespace.route("/") -class Topic(Resource): - @admins_only - @topics_namespace.doc( - description="Endpoint to get a specific Topic object", - responses={ - 200: ("Success", "TopicDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def get(self, topic_id): - topic = Topics.query.filter_by(id=topic_id).first_or_404() - response = TopicSchema().dump(topic) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return {"success": True, "data": response.data} - - @admins_only - @topics_namespace.doc( - description="Endpoint to delete a specific Topic object", - responses={200: ("Success", "APISimpleSuccessResponse")}, - ) - def delete(self, topic_id): - topic = Topics.query.filter_by(id=topic_id).first_or_404() - db.session.delete(topic) - db.session.commit() - db.session.close() - - return {"success": True} diff --git a/CTFd/api/v1/unlocks.py b/CTFd/api/v1/unlocks.py deleted file mode 100644 index f143abb7..00000000 --- a/CTFd/api/v1/unlocks.py +++ /dev/null @@ -1,164 +0,0 @@ -from typing import List - -from flask import request -from flask_restx import Namespace, Resource - -from CTFd.api.v1.helpers.request import validate_args -from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic -from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse -from CTFd.cache import clear_standings -from CTFd.constants import RawEnum -from CTFd.models import Unlocks, db, get_class_by_tablename -from CTFd.schemas.awards import AwardSchema -from CTFd.schemas.unlocks import UnlockSchema -from CTFd.utils.decorators import ( - admins_only, - authed_only, - during_ctf_time_only, - require_verified_emails, -) -from CTFd.utils.helpers.models import build_model_filters -from CTFd.utils.user import get_current_user - -unlocks_namespace = Namespace("unlocks", description="Endpoint to retrieve Unlocks") - -UnlockModel = sqlalchemy_to_pydantic(Unlocks) -TransientUnlockModel = sqlalchemy_to_pydantic(Unlocks, exclude=["id"]) - - -class UnlockDetailedSuccessResponse(APIDetailedSuccessResponse): - data: UnlockModel - - -class UnlockListSuccessResponse(APIListSuccessResponse): - data: List[UnlockModel] - - -unlocks_namespace.schema_model( - "UnlockDetailedSuccessResponse", UnlockDetailedSuccessResponse.apidoc() -) - -unlocks_namespace.schema_model( - "UnlockListSuccessResponse", UnlockListSuccessResponse.apidoc() -) - - -@unlocks_namespace.route("") -class UnlockList(Resource): - @admins_only - @unlocks_namespace.doc( - description="Endpoint to get unlock objects in bulk", - responses={ - 200: ("Success", "UnlockListSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - @validate_args( - { - "user_id": (int, None), - "team_id": (int, None), - "target": (int, None), - "type": (str, None), - "q": (str, None), - "field": ( - RawEnum("UnlockFields", {"target": "target", "type": "type"}), - None, - ), - }, - location="query", - ) - def get(self, query_args): - q = query_args.pop("q", None) - field = str(query_args.pop("field", None)) - filters = build_model_filters(model=Unlocks, query=q, field=field) - - unlocks = Unlocks.query.filter_by(**query_args).filter(*filters).all() - schema = UnlockSchema() - response = schema.dump(unlocks) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return {"success": True, "data": response.data} - - @during_ctf_time_only - @require_verified_emails - @authed_only - @unlocks_namespace.doc( - description="Endpoint to create an unlock object. Used to unlock hints.", - responses={ - 200: ("Success", "UnlockDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def post(self): - req = request.get_json() - user = get_current_user() - - req["user_id"] = user.id - req["team_id"] = user.team_id - - Model = get_class_by_tablename(req["type"]) - target = Model.query.filter_by(id=req["target"]).first_or_404() - - # We should use the team's score if in teams mode - # user.account gives the appropriate account based on team mode - if target.cost > user.account.score: - return ( - { - "success": False, - "errors": { - "score": "You do not have enough points to unlock this hint" - }, - }, - 400, - ) - - schema = UnlockSchema() - response = schema.load(req, session=db.session) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - # Search for an existing unlock that matches the target and type - # And matches either the requesting user id or the requesting team id - existing = Unlocks.query.filter( - Unlocks.target == req["target"], - Unlocks.type == req["type"], - Unlocks.account_id == user.account_id, - ).first() - if existing: - return ( - { - "success": False, - "errors": {"target": "You've already unlocked this this target"}, - }, - 400, - ) - - db.session.add(response.data) - - award_schema = AwardSchema() - award = { - "user_id": user.id, - "team_id": user.team_id, - "name": target.name, - "description": target.description, - "value": (-target.cost), - "category": target.category, - } - - award = award_schema.load(award) - db.session.add(award.data) - db.session.commit() - clear_standings() - - response = schema.dump(response.data) - - return {"success": True, "data": response.data} diff --git a/CTFd/api/v1/users.py b/CTFd/api/v1/users.py deleted file mode 100644 index 954c8ee9..00000000 --- a/CTFd/api/v1/users.py +++ /dev/null @@ -1,502 +0,0 @@ -from typing import List - -from flask import abort, request, session -from flask_restx import Namespace, Resource - -from CTFd.api.v1.helpers.request import validate_args -from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic -from CTFd.api.v1.schemas import ( - APIDetailedSuccessResponse, - PaginatedAPIListSuccessResponse, -) -from CTFd.cache import clear_challenges, clear_standings, clear_user_session -from CTFd.constants import RawEnum -from CTFd.models import ( - Awards, - Notifications, - Solves, - Submissions, - Tracking, - Unlocks, - Users, - db, -) -from CTFd.schemas.awards import AwardSchema -from CTFd.schemas.submissions import SubmissionSchema -from CTFd.schemas.users import UserSchema -from CTFd.utils.config import get_mail_provider -from CTFd.utils.decorators import admins_only, authed_only, ratelimit -from CTFd.utils.decorators.visibility import ( - check_account_visibility, - check_score_visibility, -) -from CTFd.utils.email import sendmail, user_created_notification -from CTFd.utils.helpers.models import build_model_filters -from CTFd.utils.security.auth import update_user -from CTFd.utils.user import get_current_user, get_current_user_type, is_admin - -users_namespace = Namespace("users", description="Endpoint to retrieve Users") - - -UserModel = sqlalchemy_to_pydantic(Users) -TransientUserModel = sqlalchemy_to_pydantic(Users, exclude=["id"]) - - -class UserDetailedSuccessResponse(APIDetailedSuccessResponse): - data: UserModel - - -class UserListSuccessResponse(PaginatedAPIListSuccessResponse): - data: List[UserModel] - - -users_namespace.schema_model( - "UserDetailedSuccessResponse", UserDetailedSuccessResponse.apidoc() -) - -users_namespace.schema_model( - "UserListSuccessResponse", UserListSuccessResponse.apidoc() -) - - -@users_namespace.route("") -class UserList(Resource): - @check_account_visibility - @users_namespace.doc( - description="Endpoint to get User objects in bulk", - responses={ - 200: ("Success", "UserListSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - @validate_args( - { - "affiliation": (str, None), - "country": (str, None), - "bracket": (str, None), - "q": (str, None), - "field": ( - RawEnum( - "UserFields", - { - "name": "name", - "website": "website", - "country": "country", - "bracket": "bracket", - "affiliation": "affiliation", - }, - ), - None, - ), - }, - location="query", - ) - def get(self, query_args): - q = query_args.pop("q", None) - field = str(query_args.pop("field", None)) - filters = build_model_filters(model=Users, query=q, field=field) - - if is_admin() and request.args.get("view") == "admin": - users = ( - Users.query.filter_by(**query_args) - .filter(*filters) - .paginate(per_page=50, max_per_page=100) - ) - else: - users = ( - Users.query.filter_by(banned=False, hidden=False, **query_args) - .filter(*filters) - .paginate(per_page=50, max_per_page=100) - ) - - response = UserSchema(view="user", many=True).dump(users.items) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - return { - "meta": { - "pagination": { - "page": users.page, - "next": users.next_num, - "prev": users.prev_num, - "pages": users.pages, - "per_page": users.per_page, - "total": users.total, - } - }, - "success": True, - "data": response.data, - } - - @admins_only - @users_namespace.doc( - description="Endpoint to create a User object", - responses={ - 200: ("Success", "UserDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - params={ - "notify": "Whether to send the created user an email with their credentials" - }, - ) - def post(self): - req = request.get_json() - schema = UserSchema("admin") - response = schema.load(req) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - db.session.add(response.data) - db.session.commit() - - if request.args.get("notify"): - name = response.data.name - email = response.data.email - password = req.get("password") - - user_created_notification(addr=email, name=name, password=password) - - clear_standings() - clear_challenges() - - response = schema.dump(response.data) - - return {"success": True, "data": response.data} - - -@users_namespace.route("/") -@users_namespace.param("user_id", "User ID") -class UserPublic(Resource): - @check_account_visibility - @users_namespace.doc( - description="Endpoint to get a specific User object", - responses={ - 200: ("Success", "UserDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def get(self, user_id): - user = Users.query.filter_by(id=user_id).first_or_404() - - if (user.banned or user.hidden) and is_admin() is False: - abort(404) - - user_type = get_current_user_type(fallback="user") - response = UserSchema(view=user_type).dump(user) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - response.data["place"] = user.place - response.data["score"] = user.score - - return {"success": True, "data": response.data} - - @admins_only - @users_namespace.doc( - description="Endpoint to edit a specific User object", - responses={ - 200: ("Success", "UserDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def patch(self, user_id): - user = Users.query.filter_by(id=user_id).first_or_404() - data = request.get_json() - data["id"] = user_id - - # Admins should not be able to ban themselves - if data["id"] == session["id"] and ( - data.get("banned") is True or data.get("banned") == "true" - ): - return ( - {"success": False, "errors": {"id": "You cannot ban yourself"}}, - 400, - ) - - schema = UserSchema(view="admin", instance=user, partial=True) - response = schema.load(data) - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - # This generates the response first before actually changing the type - # This avoids an error during User type changes where we change - # the polymorphic identity resulting in an ObjectDeletedError - # https://github.com/CTFd/CTFd/issues/1794 - response = schema.dump(response.data) - db.session.commit() - db.session.close() - - clear_user_session(user_id=user_id) - clear_standings() - clear_challenges() - - return {"success": True, "data": response.data} - - @admins_only - @users_namespace.doc( - description="Endpoint to delete a specific User object", - responses={200: ("Success", "APISimpleSuccessResponse")}, - ) - def delete(self, user_id): - # Admins should not be able to delete themselves - if user_id == session["id"]: - return ( - {"success": False, "errors": {"id": "You cannot delete yourself"}}, - 400, - ) - - Notifications.query.filter_by(user_id=user_id).delete() - Awards.query.filter_by(user_id=user_id).delete() - Unlocks.query.filter_by(user_id=user_id).delete() - Submissions.query.filter_by(user_id=user_id).delete() - Solves.query.filter_by(user_id=user_id).delete() - Tracking.query.filter_by(user_id=user_id).delete() - Users.query.filter_by(id=user_id).delete() - db.session.commit() - db.session.close() - - clear_user_session(user_id=user_id) - clear_standings() - clear_challenges() - - return {"success": True} - - -@users_namespace.route("/me") -class UserPrivate(Resource): - @authed_only - @users_namespace.doc( - description="Endpoint to get the User object for the current user", - responses={ - 200: ("Success", "UserDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def get(self): - user = get_current_user() - response = UserSchema("self").dump(user).data - response["place"] = user.place - response["score"] = user.score - return {"success": True, "data": response} - - @authed_only - @users_namespace.doc( - description="Endpoint to edit the User object for the current user", - responses={ - 200: ("Success", "UserDetailedSuccessResponse"), - 400: ( - "An error occured processing the provided or stored data", - "APISimpleErrorResponse", - ), - }, - ) - def patch(self): - user = get_current_user() - data = request.get_json() - schema = UserSchema(view="self", instance=user, partial=True) - response = schema.load(data) - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - db.session.commit() - - # Update user's session for the new session hash - update_user(user) - - response = schema.dump(response.data) - db.session.close() - - clear_standings() - clear_challenges() - - return {"success": True, "data": response.data} - - -@users_namespace.route("/me/solves") -class UserPrivateSolves(Resource): - @authed_only - def get(self): - user = get_current_user() - solves = user.get_solves(admin=True) - - view = "user" if not is_admin() else "admin" - response = SubmissionSchema(view=view, many=True).dump(solves) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - count = len(response.data) - return {"success": True, "data": response.data, "meta": {"count": count}} - - -@users_namespace.route("/me/fails") -class UserPrivateFails(Resource): - @authed_only - def get(self): - user = get_current_user() - fails = user.get_fails(admin=True) - - view = "user" if not is_admin() else "admin" - - # We want to return the count purely for stats & graphs - # but this data isn't really needed by the end user. - # Only actually show fail data for admins. - if is_admin(): - response = SubmissionSchema(view=view, many=True).dump(fails) - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - data = response.data - else: - data = [] - - count = len(fails) - return {"success": True, "data": data, "meta": {"count": count}} - - -@users_namespace.route("/me/awards") -@users_namespace.param("user_id", "User ID") -class UserPrivateAwards(Resource): - @authed_only - def get(self): - user = get_current_user() - awards = user.get_awards(admin=True) - - view = "user" if not is_admin() else "admin" - response = AwardSchema(view=view, many=True).dump(awards) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - count = len(response.data) - return {"success": True, "data": response.data, "meta": {"count": count}} - - -@users_namespace.route("//solves") -@users_namespace.param("user_id", "User ID") -class UserPublicSolves(Resource): - @check_account_visibility - @check_score_visibility - def get(self, user_id): - user = Users.query.filter_by(id=user_id).first_or_404() - - if (user.banned or user.hidden) and is_admin() is False: - abort(404) - - solves = user.get_solves(admin=is_admin()) - - view = "user" if not is_admin() else "admin" - response = SubmissionSchema(view=view, many=True).dump(solves) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - count = len(response.data) - return {"success": True, "data": response.data, "meta": {"count": count}} - - -@users_namespace.route("//fails") -@users_namespace.param("user_id", "User ID") -class UserPublicFails(Resource): - @check_account_visibility - @check_score_visibility - def get(self, user_id): - user = Users.query.filter_by(id=user_id).first_or_404() - - if (user.banned or user.hidden) and is_admin() is False: - abort(404) - fails = user.get_fails(admin=is_admin()) - - view = "user" if not is_admin() else "admin" - - # We want to return the count purely for stats & graphs - # but this data isn't really needed by the end user. - # Only actually show fail data for admins. - if is_admin(): - response = SubmissionSchema(view=view, many=True).dump(fails) - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - data = response.data - else: - data = [] - - count = len(fails) - return {"success": True, "data": data, "meta": {"count": count}} - - -@users_namespace.route("//awards") -@users_namespace.param("user_id", "User ID or 'me'") -class UserPublicAwards(Resource): - @check_account_visibility - @check_score_visibility - def get(self, user_id): - user = Users.query.filter_by(id=user_id).first_or_404() - - if (user.banned or user.hidden) and is_admin() is False: - abort(404) - awards = user.get_awards(admin=is_admin()) - - view = "user" if not is_admin() else "admin" - response = AwardSchema(view=view, many=True).dump(awards) - - if response.errors: - return {"success": False, "errors": response.errors}, 400 - - count = len(response.data) - return {"success": True, "data": response.data, "meta": {"count": count}} - - -@users_namespace.route("//email") -@users_namespace.param("user_id", "User ID") -class UserEmails(Resource): - @admins_only - @users_namespace.doc( - description="Endpoint to email a User object", - responses={200: ("Success", "APISimpleSuccessResponse")}, - ) - @ratelimit(method="POST", limit=10, interval=60) - def post(self, user_id): - req = request.get_json() - text = req.get("text", "").strip() - user = Users.query.filter_by(id=user_id).first_or_404() - - if get_mail_provider() is None: - return ( - {"success": False, "errors": {"": ["Email settings not configured"]}}, - 400, - ) - - if not text: - return ( - {"success": False, "errors": {"text": ["Email text cannot be empty"]}}, - 400, - ) - - result, response = sendmail(addr=user.email, text=text) - - if result is True: - return {"success": True} - else: - return ( - {"success": False, "errors": {"": [response]}}, - 400, - ) diff --git a/CTFd/auth.py b/CTFd/auth.py deleted file mode 100644 index 739558ea..00000000 --- a/CTFd/auth.py +++ /dev/null @@ -1,569 +0,0 @@ -import base64 # noqa: I001 - -import requests -from flask import Blueprint, abort -from flask import current_app as app -from flask import redirect, render_template, request, session, url_for -from itsdangerous.exc import BadSignature, BadTimeSignature, SignatureExpired - -from CTFd.cache import clear_team_session, clear_user_session -from CTFd.models import Teams, UserFieldEntries, UserFields, Users, db -from CTFd.utils import config, email, get_app_config, get_config -from CTFd.utils import user as current_user -from CTFd.utils import validators -from CTFd.utils.config import is_teams_mode -from CTFd.utils.config.integrations import mlc_registration -from CTFd.utils.config.visibility import registration_visible -from CTFd.utils.crypto import verify_password -from CTFd.utils.decorators import ratelimit -from CTFd.utils.decorators.visibility import check_registration_visibility -from CTFd.utils.helpers import error_for, get_errors, markup -from CTFd.utils.logging import log -from CTFd.utils.modes import TEAMS_MODE -from CTFd.utils.security.auth import login_user, logout_user -from CTFd.utils.security.signing import unserialize -from CTFd.utils.validators import ValidationError - -auth = Blueprint("auth", __name__) - - -@auth.route("/confirm", methods=["POST", "GET"]) -@auth.route("/confirm/", methods=["POST", "GET"]) -@ratelimit(method="POST", limit=10, interval=60) -def confirm(data=None): - if not get_config("verify_emails"): - # If the CTF doesn't care about confirming email addresses then redierct to challenges - return redirect(url_for("challenges.listing")) - - # User is confirming email account - if data and request.method == "GET": - try: - user_email = unserialize(data, max_age=1800) - except (BadTimeSignature, SignatureExpired): - return render_template( - "confirm.html", errors=["Your confirmation link has expired"] - ) - except (BadSignature, TypeError, base64.binascii.Error): - return render_template( - "confirm.html", errors=["Your confirmation token is invalid"] - ) - - user = Users.query.filter_by(email=user_email).first_or_404() - if user.verified: - return redirect(url_for("views.settings")) - - user.verified = True - log( - "registrations", - format="[{date}] {ip} - successful confirmation for {name}", - name=user.name, - ) - db.session.commit() - clear_user_session(user_id=user.id) - email.successful_registration_notification(user.email) - db.session.close() - if current_user.authed(): - return redirect(url_for("challenges.listing")) - return redirect(url_for("auth.login")) - - # User is trying to start or restart the confirmation flow - if current_user.authed() is False: - return redirect(url_for("auth.login")) - - user = Users.query.filter_by(id=session["id"]).first_or_404() - if user.verified: - return redirect(url_for("views.settings")) - - if data is None: - if request.method == "POST": - # User wants to resend their confirmation email - email.verify_email_address(user.email) - log( - "registrations", - format="[{date}] {ip} - {name} initiated a confirmation email resend", - name=user.name, - ) - return render_template( - "confirm.html", infos=[f"Confirmation email sent to {user.email}!"] - ) - elif request.method == "GET": - # User has been directed to the confirm page - return render_template("confirm.html") - - -@auth.route("/reset_password", methods=["POST", "GET"]) -@auth.route("/reset_password/", methods=["POST", "GET"]) -@ratelimit(method="POST", limit=10, interval=60) -def reset_password(data=None): - if config.can_send_mail() is False: - return render_template( - "reset_password.html", - errors=[ - markup( - "This CTF is not configured to send email.
Please contact an organizer to have your password reset." - ) - ], - ) - - if data is not None: - try: - email_address = unserialize(data, max_age=1800) - except (BadTimeSignature, SignatureExpired): - return render_template( - "reset_password.html", errors=["Your link has expired"] - ) - except (BadSignature, TypeError, base64.binascii.Error): - return render_template( - "reset_password.html", errors=["Your reset token is invalid"] - ) - - if request.method == "GET": - return render_template("reset_password.html", mode="set") - if request.method == "POST": - password = request.form.get("password", "").strip() - user = Users.query.filter_by(email=email_address).first_or_404() - if user.oauth_id: - return render_template( - "reset_password.html", - infos=[ - "Your account was registered via an authentication provider and does not have an associated password. Please login via your authentication provider." - ], - ) - - pass_short = len(password) == 0 - if pass_short: - return render_template( - "reset_password.html", errors=["Please pick a longer password"] - ) - - user.password = password - db.session.commit() - clear_user_session(user_id=user.id) - log( - "logins", - format="[{date}] {ip} - successful password reset for {name}", - name=user.name, - ) - db.session.close() - email.password_change_alert(user.email) - return redirect(url_for("auth.login")) - - if request.method == "POST": - email_address = request.form["email"].strip() - user = Users.query.filter_by(email=email_address).first() - - get_errors() - - if not user: - return render_template( - "reset_password.html", - infos=[ - "If that account exists you will receive an email, please check your inbox" - ], - ) - - if user.oauth_id: - return render_template( - "reset_password.html", - infos=[ - "The email address associated with this account was registered via an authentication provider and does not have an associated password. Please login via your authentication provider." - ], - ) - - email.forgot_password(email_address) - - return render_template( - "reset_password.html", - infos=[ - "If that account exists you will receive an email, please check your inbox" - ], - ) - return render_template("reset_password.html") - - -@auth.route("/register", methods=["POST", "GET"]) -@check_registration_visibility -@ratelimit(method="POST", limit=10, interval=5) -def register(): - errors = get_errors() - if current_user.authed(): - return redirect(url_for("challenges.listing")) - - if request.method == "POST": - name = request.form.get("name", "").strip() - email_address = request.form.get("email", "").strip().lower() - password = request.form.get("password", "").strip() - - website = request.form.get("website") - affiliation = request.form.get("affiliation") - country = request.form.get("country") - registration_code = str(request.form.get("registration_code", "")) - - name_len = len(name) == 0 - names = Users.query.add_columns("name", "id").filter_by(name=name).first() - emails = ( - Users.query.add_columns("email", "id") - .filter_by(email=email_address) - .first() - ) - pass_short = len(password) == 0 - pass_long = len(password) > 128 - valid_email = validators.validate_email(email_address) - team_name_email_check = validators.validate_email(name) - - if get_config("registration_code"): - if ( - registration_code.lower() - != str(get_config("registration_code", default="")).lower() - ): - errors.append("The registration code you entered was incorrect") - - # Process additional user fields - fields = {} - for field in UserFields.query.all(): - fields[field.id] = field - - entries = {} - for field_id, field in fields.items(): - value = request.form.get(f"fields[{field_id}]", "").strip() - if field.required is True and (value is None or value == ""): - errors.append("Please provide all required fields") - break - - # Handle special casing of existing profile fields - if field.name.lower() == "affiliation": - affiliation = value - break - elif field.name.lower() == "website": - website = value - break - - if field.field_type == "boolean": - entries[field_id] = bool(value) - else: - entries[field_id] = value - - if country: - try: - validators.validate_country_code(country) - valid_country = True - except ValidationError: - valid_country = False - else: - valid_country = True - - if website: - valid_website = validators.validate_url(website) - else: - valid_website = True - - if affiliation: - valid_affiliation = len(affiliation) < 128 - else: - valid_affiliation = True - - if not valid_email: - errors.append("Please enter a valid email address") - if email.check_email_is_whitelisted(email_address) is False: - errors.append("Your email address is not from an allowed domain") - if names: - errors.append("That user name is already taken") - if team_name_email_check is True: - errors.append("Your user name cannot be an email address") - if emails: - errors.append("That email has already been used") - if pass_short: - errors.append("Pick a longer password") - if pass_long: - errors.append("Pick a shorter password") - if name_len: - errors.append("Pick a longer user name") - if valid_website is False: - errors.append("Websites must be a proper URL starting with http or https") - if valid_country is False: - errors.append("Invalid country") - if valid_affiliation is False: - errors.append("Please provide a shorter affiliation") - - if len(errors) > 0: - return render_template( - "register.html", - errors=errors, - name=request.form["name"], - email=request.form["email"], - password=request.form["password"], - ) - else: - with app.app_context(): - user = Users(name=name, email=email_address, password=password) - - if website: - user.website = website - if affiliation: - user.affiliation = affiliation - if country: - user.country = country - - db.session.add(user) - db.session.commit() - db.session.flush() - - for field_id, value in entries.items(): - entry = UserFieldEntries( - field_id=field_id, value=value, user_id=user.id - ) - db.session.add(entry) - db.session.commit() - - login_user(user) - - if request.args.get("next") and validators.is_safe_url( - request.args.get("next") - ): - return redirect(request.args.get("next")) - - if config.can_send_mail() and get_config( - "verify_emails" - ): # Confirming users is enabled and we can send email. - log( - "registrations", - format="[{date}] {ip} - {name} registered (UNCONFIRMED) with {email}", - name=user.name, - email=user.email, - ) - email.verify_email_address(user.email) - db.session.close() - return redirect(url_for("auth.confirm")) - else: # Don't care about confirming users - if ( - config.can_send_mail() - ): # We want to notify the user that they have registered. - email.successful_registration_notification(user.email) - - log( - "registrations", - format="[{date}] {ip} - {name} registered with {email}", - name=user.name, - email=user.email, - ) - db.session.close() - - if is_teams_mode(): - return redirect(url_for("teams.private")) - - return redirect(url_for("challenges.listing")) - else: - return render_template("register.html", errors=errors) - - -@auth.route("/login", methods=["POST", "GET"]) -@ratelimit(method="POST", limit=10, interval=5) -def login(): - errors = get_errors() - if request.method == "POST": - name = request.form["name"] - - # Check if the user submitted an email address or a team name - if validators.validate_email(name) is True: - user = Users.query.filter_by(email=name).first() - else: - user = Users.query.filter_by(name=name).first() - - if user: - if user.password is None: - errors.append( - "Your account was registered with a 3rd party authentication provider. " - "Please try logging in with a configured authentication provider." - ) - return render_template("login.html", errors=errors) - - if user and verify_password(request.form["password"], user.password): - session.regenerate() - - login_user(user) - log("logins", "[{date}] {ip} - {name} logged in", name=user.name) - - db.session.close() - if request.args.get("next") and validators.is_safe_url( - request.args.get("next") - ): - return redirect(request.args.get("next")) - return redirect(url_for("challenges.listing")) - - else: - # This user exists but the password is wrong - log( - "logins", - "[{date}] {ip} - submitted invalid password for {name}", - name=user.name, - ) - errors.append("Your username or password is incorrect") - db.session.close() - return render_template("login.html", errors=errors) - else: - # This user just doesn't exist - log("logins", "[{date}] {ip} - submitted invalid account information") - errors.append("Your username or password is incorrect") - db.session.close() - return render_template("login.html", errors=errors) - else: - db.session.close() - return render_template("login.html", errors=errors) - - -@auth.route("/oauth") -def oauth_login(): - endpoint = ( - get_app_config("OAUTH_AUTHORIZATION_ENDPOINT") - or get_config("oauth_authorization_endpoint") - or "https://auth.majorleaguecyber.org/oauth/authorize" - ) - - if get_config("user_mode") == "teams": - scope = "profile team" - else: - scope = "profile" - - client_id = get_app_config("OAUTH_CLIENT_ID") or get_config("oauth_client_id") - - if client_id is None: - error_for( - endpoint="auth.login", - message="OAuth Settings not configured. " - "Ask your CTF administrator to configure MajorLeagueCyber integration.", - ) - return redirect(url_for("auth.login")) - - redirect_url = "{endpoint}?response_type=code&client_id={client_id}&scope={scope}&state={state}".format( - endpoint=endpoint, client_id=client_id, scope=scope, state=session["nonce"] - ) - return redirect(redirect_url) - - -@auth.route("/redirect", methods=["GET"]) -@ratelimit(method="GET", limit=10, interval=60) -def oauth_redirect(): - oauth_code = request.args.get("code") - state = request.args.get("state") - if session["nonce"] != state: - log("logins", "[{date}] {ip} - OAuth State validation mismatch") - error_for(endpoint="auth.login", message="OAuth State validation mismatch.") - return redirect(url_for("auth.login")) - - if oauth_code: - url = ( - get_app_config("OAUTH_TOKEN_ENDPOINT") - or get_config("oauth_token_endpoint") - or "https://auth.majorleaguecyber.org/oauth/token" - ) - - client_id = get_app_config("OAUTH_CLIENT_ID") or get_config("oauth_client_id") - client_secret = get_app_config("OAUTH_CLIENT_SECRET") or get_config( - "oauth_client_secret" - ) - headers = {"content-type": "application/x-www-form-urlencoded"} - data = { - "code": oauth_code, - "client_id": client_id, - "client_secret": client_secret, - "grant_type": "authorization_code", - } - token_request = requests.post(url, data=data, headers=headers) - - if token_request.status_code == requests.codes.ok: - token = token_request.json()["access_token"] - user_url = ( - get_app_config("OAUTH_API_ENDPOINT") - or get_config("oauth_api_endpoint") - or "https://api.majorleaguecyber.org/user" - ) - - headers = { - "Authorization": "Bearer " + str(token), - "Content-type": "application/json", - } - api_data = requests.get(url=user_url, headers=headers).json() - - user_id = api_data["id"] - user_name = api_data["name"] - user_email = api_data["email"] - - user = Users.query.filter_by(email=user_email).first() - if user is None: - # Check if we are allowing registration before creating users - if registration_visible() or mlc_registration(): - user = Users( - name=user_name, - email=user_email, - oauth_id=user_id, - verified=True, - ) - db.session.add(user) - db.session.commit() - else: - log("logins", "[{date}] {ip} - Public registration via MLC blocked") - error_for( - endpoint="auth.login", - message="Public registration is disabled. Please try again later.", - ) - return redirect(url_for("auth.login")) - - if get_config("user_mode") == TEAMS_MODE and user.team_id is None: - team_id = api_data["team"]["id"] - team_name = api_data["team"]["name"] - - team = Teams.query.filter_by(oauth_id=team_id).first() - if team is None: - num_teams_limit = int(get_config("num_teams", default=0)) - num_teams = Teams.query.filter_by( - banned=False, hidden=False - ).count() - if num_teams_limit and num_teams >= num_teams_limit: - abort( - 403, - description=f"Reached the maximum number of teams ({num_teams_limit}). Please join an existing team.", - ) - - team = Teams(name=team_name, oauth_id=team_id, captain_id=user.id) - db.session.add(team) - db.session.commit() - clear_team_session(team_id=team.id) - - team_size_limit = get_config("team_size", default=0) - if team_size_limit and len(team.members) >= team_size_limit: - plural = "" if team_size_limit == 1 else "s" - size_error = "Teams are limited to {limit} member{plural}.".format( - limit=team_size_limit, plural=plural - ) - error_for(endpoint="auth.login", message=size_error) - return redirect(url_for("auth.login")) - - team.members.append(user) - db.session.commit() - - if user.oauth_id is None: - user.oauth_id = user_id - user.verified = True - db.session.commit() - clear_user_session(user_id=user.id) - - login_user(user) - - return redirect(url_for("challenges.listing")) - else: - log("logins", "[{date}] {ip} - OAuth token retrieval failure") - error_for(endpoint="auth.login", message="OAuth token retrieval failure.") - return redirect(url_for("auth.login")) - else: - log("logins", "[{date}] {ip} - Received redirect without OAuth code") - error_for( - endpoint="auth.login", message="Received redirect without OAuth code." - ) - return redirect(url_for("auth.login")) - - -@auth.route("/logout") -def logout(): - if current_user.authed(): - logout_user() - return redirect(url_for("views.static_html")) diff --git a/CTFd/cache/__init__.py b/CTFd/cache/__init__.py deleted file mode 100644 index bcbcc35c..00000000 --- a/CTFd/cache/__init__.py +++ /dev/null @@ -1,167 +0,0 @@ -from functools import lru_cache, wraps -from time import monotonic_ns - -from flask import request -from flask_caching import Cache, make_template_fragment_key - -cache = Cache() - - -def timed_lru_cache(timeout: int = 300, maxsize: int = 64, typed: bool = False): - """ - lru_cache implementation that includes a time based expiry - - Parameters: - seconds (int): Timeout in seconds to clear the WHOLE cache, default = 5 minutes - maxsize (int): Maximum Size of the Cache - typed (bool): Same value of different type will be a different entry - - Implmentation from https://gist.github.com/Morreski/c1d08a3afa4040815eafd3891e16b945?permalink_comment_id=3437689#gistcomment-3437689 - """ - - def wrapper_cache(func): - func = lru_cache(maxsize=maxsize, typed=typed)(func) - func.delta = timeout * 10 ** 9 - func.expiration = monotonic_ns() + func.delta - - @wraps(func) - def wrapped_func(*args, **kwargs): - if monotonic_ns() >= func.expiration: - func.cache_clear() - func.expiration = monotonic_ns() + func.delta - return func(*args, **kwargs) - - wrapped_func.cache_info = func.cache_info - wrapped_func.cache_clear = func.cache_clear - return wrapped_func - - return wrapper_cache - - -def make_cache_key(path=None, key_prefix="view/%s"): - """ - This function mostly emulates Flask-Caching's `make_cache_key` function so we can delete cached api responses. - Over time this function may be replaced with a cleaner custom cache implementation. - :param path: - :param key_prefix: - :return: - """ - if path is None: - path = request.endpoint - cache_key = key_prefix % path - return cache_key - - -def clear_config(): - from CTFd.utils import _get_config, get_app_config - - cache.delete_memoized(_get_config) - cache.delete_memoized(get_app_config) - - -def clear_standings(): - from CTFd.models import Users, Teams # noqa: I001 - from CTFd.constants.static import CacheKeys - from CTFd.utils.scores import get_standings, get_team_standings, get_user_standings - from CTFd.api.v1.scoreboard import ScoreboardDetail, ScoreboardList - from CTFd.api import api - from CTFd.utils.user import ( - get_user_score, - get_user_place, - get_team_score, - get_team_place, - ) - - # Clear out the bulk standings functions - cache.delete_memoized(get_standings) - cache.delete_memoized(get_team_standings) - cache.delete_memoized(get_user_standings) - - # Clear out the individual helpers for accessing score via the model - cache.delete_memoized(Users.get_score) - cache.delete_memoized(Users.get_place) - cache.delete_memoized(Teams.get_score) - cache.delete_memoized(Teams.get_place) - - # Clear the Jinja Attrs constants - cache.delete_memoized(get_user_score) - cache.delete_memoized(get_user_place) - cache.delete_memoized(get_team_score) - cache.delete_memoized(get_team_place) - - # Clear out HTTP request responses - cache.delete(make_cache_key(path=api.name + "." + ScoreboardList.endpoint)) - cache.delete(make_cache_key(path=api.name + "." + ScoreboardDetail.endpoint)) - cache.delete_memoized(ScoreboardList.get) - - # Clear out scoreboard templates - cache.delete(make_template_fragment_key(CacheKeys.PUBLIC_SCOREBOARD_TABLE)) - - -def clear_challenges(): - from CTFd.utils.challenges import get_all_challenges # noqa: I001 - from CTFd.utils.challenges import get_solves_for_challenge_id - from CTFd.utils.challenges import get_solve_ids_for_user_id - from CTFd.utils.challenges import get_solve_counts_for_challenges - - cache.delete_memoized(get_all_challenges) - cache.delete_memoized(get_solves_for_challenge_id) - cache.delete_memoized(get_solve_ids_for_user_id) - cache.delete_memoized(get_solve_counts_for_challenges) - - -def clear_pages(): - from CTFd.utils.config.pages import get_page, get_pages - - cache.delete_memoized(get_pages) - cache.delete_memoized(get_page) - - -def clear_user_recent_ips(user_id): - from CTFd.utils.user import get_user_recent_ips - - cache.delete_memoized(get_user_recent_ips, user_id=user_id) - - -def clear_user_session(user_id): - from CTFd.utils.user import ( # noqa: I001 - get_user_attrs, - get_user_place, - get_user_score, - get_user_recent_ips, - ) - - cache.delete_memoized(get_user_attrs, user_id=user_id) - cache.delete_memoized(get_user_place, user_id=user_id) - cache.delete_memoized(get_user_score, user_id=user_id) - cache.delete_memoized(get_user_recent_ips, user_id=user_id) - - -def clear_all_user_sessions(): - from CTFd.utils.user import ( # noqa: I001 - get_user_attrs, - get_user_place, - get_user_score, - get_user_recent_ips, - ) - - cache.delete_memoized(get_user_attrs) - cache.delete_memoized(get_user_place) - cache.delete_memoized(get_user_score) - cache.delete_memoized(get_user_recent_ips) - - -def clear_team_session(team_id): - from CTFd.utils.user import get_team_attrs, get_team_place, get_team_score - - cache.delete_memoized(get_team_attrs, team_id=team_id) - cache.delete_memoized(get_team_place, team_id=team_id) - cache.delete_memoized(get_team_score, team_id=team_id) - - -def clear_all_team_sessions(): - from CTFd.utils.user import get_team_attrs, get_team_place, get_team_score - - cache.delete_memoized(get_team_attrs) - cache.delete_memoized(get_team_place) - cache.delete_memoized(get_team_score) diff --git a/CTFd/challenges.py b/CTFd/challenges.py deleted file mode 100644 index 1ab223dc..00000000 --- a/CTFd/challenges.py +++ /dev/null @@ -1,48 +0,0 @@ -from flask import Blueprint, redirect, render_template, request, url_for - -from CTFd.constants.config import ChallengeVisibilityTypes, Configs -from CTFd.utils.config import is_teams_mode -from CTFd.utils.dates import ctf_ended, ctf_paused, ctf_started -from CTFd.utils.decorators import ( - during_ctf_time_only, - require_complete_profile, - require_verified_emails, -) -from CTFd.utils.decorators.visibility import check_challenge_visibility -from CTFd.utils.helpers import get_errors, get_infos -from CTFd.utils.user import authed, get_current_team - -challenges = Blueprint("challenges", __name__) - - -@challenges.route("/challenges", methods=["GET"]) -@require_complete_profile -@during_ctf_time_only -@require_verified_emails -@check_challenge_visibility -def listing(): - if ( - Configs.challenge_visibility == ChallengeVisibilityTypes.PUBLIC - and authed() is False - ): - pass - else: - if is_teams_mode() and get_current_team() is None: - return redirect(url_for("teams.private", next=request.full_path)) - - infos = get_infos() - errors = get_errors() - - if Configs.challenge_visibility == ChallengeVisibilityTypes.ADMINS: - infos.append("Challenge Visibility is set to Admins Only") - - if ctf_started() is False: - errors.append(f"{Configs.ctf_name} has not started yet") - - if ctf_paused() is True: - infos.append(f"{Configs.ctf_name} is paused") - - if ctf_ended() is True: - infos.append(f"{Configs.ctf_name} has ended") - - return render_template("challenges.html", infos=infos, errors=errors) diff --git a/CTFd/config.ini b/CTFd/config.ini deleted file mode 100644 index 6db6db99..00000000 --- a/CTFd/config.ini +++ /dev/null @@ -1,280 +0,0 @@ -# CTFd Configuration File -# -# Use this file to configure aspects of how CTFd behaves. Additional attributes can be specified for -# plugins and other additional behavior. -# -# If a configuration item is specified but left empty, CTFd will do the following: -# -# 1. Look for an environment variable under the same name and use that value if found -# 2. Use a default value specified in it's own internal configuration -# 3. Use a null value (i.e. None) or empty string for the configuration value - - -[server] -# SECRET_KEY: -# The secret value used to creation sessions and sign strings. This should be set to a random string. In the -# interest of ease, CTFd will automatically create a secret key file for you. If you wish to add this secret key -# to your instance you should hard code this value to a random static value. -# -# You can also remove .ctfd_secret_key from the .gitignore file and commit this file into whatever repository -# you are using. -# -# http://flask.pocoo.org/docs/latest/quickstart/#sessions -SECRET_KEY = - -# DATABASE_URL -# The URI that specifies the username, password, hostname, port, and database of the server -# used to hold the CTFd database. -# -# If neither this setting nor `DATABASE_HOST` is specified, CTFd will automatically create a SQLite database for you to use -# e.g. mysql+pymysql://root:@localhost/ctfd -DATABASE_URL = - -# DATABASE_HOST -# The hostname of the database server used to hold the CTFd database. -# If `DATABASE_URL` is set, this setting will have no effect. -# -# This option, along with the other `DATABASE_*` options, are an alternative to specifying all connection details in the single `DATABASE_URL`. -# If neither this setting nor `DATABASE_URL` is specified, CTFd will automatically create a SQLite database for you to use. -DATABASE_HOST = - -# DATABASE_PROTOCOL -# The protocol used to access the database server, if `DATABASE_HOST` is set. Defaults to `mysql+pymysql`. -DATABASE_PROTOCOL = - -# DATABASE_USER -# The username used to access the database server, if `DATABASE_HOST` is set. Defaults to `ctfd`. -DATABASE_USER = - -# DATABASE_PASSWORD -# The password used to access the database server, if `DATABASE_HOST` is set. -DATABASE_PASSWORD = - -# DATABASE_PORT -# The port used to access the database server, if `DATABASE_HOST` is set. -DATABASE_PORT = - -# DATABASE_NAME -# The name of the database to access on the database server, if `DATABASE_HOST` is set. Defaults to `ctfd`. -DATABASE_NAME = - -# REDIS_URL -# The URL to connect to a Redis server. If neither this setting nor `REDIS_HOST` is specified, -# CTFd will use the .data folder as a filesystem cache. -# -# e.g. redis://user:password@localhost:6379 -# http://pythonhosted.org/Flask-Caching/#configuring-flask-caching -REDIS_URL = - -# REDIS_HOST -# The hostname of the Redis server to connect to. -# If `REDIS_URL` is set, this setting will have no effect. -# -# This option, along with the other `REDIS_*` options, are an alternative to specifying all connection details in the single `REDIS_URL`. -# If neither this setting nor `REDIS_URL` is specified, CTFd will use the .data folder as a filesystem cache. -REDIS_HOST = - -# REDIS_PROTOCOL -# The protocol used to access the Redis server, if `REDIS_HOST` is set. Defaults to `redis`. -# -# Note that the `unix` protocol is not supported here; use `REDIS_URL` instead. -REDIS_PROTOCOL = - -# REDIS_USER -# The username used to access the Redis server, if `REDIS_HOST` is set. -REDIS_USER = - -# REDIS_PASSWORD -# The password used to access the Redis server, if `REDIS_HOST` is set. -REDIS_PASSWORD = - -# REDIS_PORT -# The port used to access the Redis server, if `REDIS_HOST` is set. -REDIS_PORT = - -# REDIS_DB -# The index of the Redis database to access, if `REDIS_HOST` is set. -REDIS_DB = - -[security] -# SESSION_COOKIE_HTTPONLY -# Controls if cookies should be set with the HttpOnly flag. Defaults to True. -SESSION_COOKIE_HTTPONLY = true - -# SESSION_COOKIE_SAMESITE -# Controls the SameSite attribute on session cookies. Can be Lax or Strict. -# Should be left as Lax unless the implications are well understood -SESSION_COOKIE_SAMESITE = Lax - -# PERMANENT_SESSION_LIFETIME -# The lifetime of a session. The default is 604800 seconds (7 days). -PERMANENT_SESSION_LIFETIME = 604800 - -[email] -# MAILFROM_ADDR -# The email address that emails are sent from if not overridden in the configuration panel. -MAILFROM_ADDR = - -# MAIL_SERVER -# The mail server that emails are sent from if not overriden in the configuration panel. -MAIL_SERVER = - -# MAIL_PORT -# The mail port that emails are sent from if not overriden in the configuration panel. -MAIL_PORT = - -# MAIL_USEAUTH -# Whether or not to use username and password to authenticate to the SMTP server -MAIL_USEAUTH = - -# MAIL_USERNAME -# The username used to authenticate to the SMTP server if MAIL_USEAUTH is defined -MAIL_USERNAME = - -# MAIL_PASSWORD -# The password used to authenticate to the SMTP server if MAIL_USEAUTH is defined -MAIL_PASSWORD = - -# MAIL_TLS -# Whether to connect to the SMTP server over TLS -MAIL_TLS = - -# MAIL_SSL -# Whether to connect to the SMTP server over SSL -MAIL_SSL = - -# MAILSENDER_ADDR -# The email address that is responsible for the transmission of emails. -# This is very often the MAILFROM_ADDR value but can be specified if your email -# is delivered by a different domain than what's specified in your MAILFROM_ADDR. -# If this isn't specified, the MAILFROM_ADDR value is used. -# It is fairly rare to need to set this value. -MAILSENDER_ADDR = - -# MAILGUN_API_KEY -# Mailgun API key to send email over Mailgun. As of CTFd v3, Mailgun integration is deprecated. -# Installations using the Mailgun API should migrate over to SMTP settings. -MAILGUN_API_KEY = - -# MAILGUN_BASE_URL -# Mailgun base url to send email over Mailgun. As of CTFd v3, Mailgun integration is deprecated. -# Installations using the Mailgun API should migrate over to SMTP settings. -MAILGUN_BASE_URL = - -# MAIL_PROVIDER -# Specifies the email provider that CTFd will use to send email. -# By default CTFd will automatically detect the correct email provider based on the other settings -# specified here or in the configuration panel. This setting can be used to force a specific provider. -MAIL_PROVIDER = - -[uploads] -# UPLOAD_PROVIDER -# Specifies the service that CTFd should use to store files. -# Can be set to filesystem or s3 -UPLOAD_PROVIDER = - -# UPLOAD_FOLDER -# The location where files are uploaded under the filesystem uploader. -# The default destination is the CTFd/uploads folder. -UPLOAD_FOLDER = - -# AWS_ACCESS_KEY_ID -# AWS access token used to authenticate to the S3 bucket. Only used under the s3 uploader. -AWS_ACCESS_KEY_ID = - -# AWS_SECRET_ACCESS_KEY -# AWS secret token used to authenticate to the S3 bucket. Only used under the s3 uploader. -AWS_SECRET_ACCESS_KEY = - -# AWS_S3_BUCKET -# The unique identifier for your S3 bucket. Only used under the s3 uploader. -AWS_S3_BUCKET = - -# AWS_S3_ENDPOINT_URL -# A URL pointing to a custom S3 implementation. Only used under the s3 uploader. -AWS_S3_ENDPOINT_URL = - -# AWS_S3_REGION -# The aws region that hosts your bucket. Only used in the s3 uploader. -AWS_S3_REGION = - -[logs] -# LOG_FOLDER -# The location where logs are written. These are the logs for CTFd key submissions, registrations, and logins. The default location is the CTFd/logs folder. -LOG_FOLDER = - -[optional] -# REVERSE_PROXY -# Specifies whether CTFd is behind a reverse proxy or not. Set to true if using a reverse proxy like nginx. -# You can also specify a comma seperated set of numbers specifying the reverse proxy configuration settings. -# See https://werkzeug.palletsprojects.com/en/0.15.x/middleware/proxy_fix/#werkzeug.middleware.proxy_fix.ProxyFix. -# For example to configure `x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1` specify `1,1,1,1,1`. -# If you specify `true` CTFd will default to the above behavior with all proxy settings set to 1. -REVERSE_PROXY = - -# THEME_FALLBACK -# Specifies whether CTFd will fallback to the default "core" theme for missing pages/content. Useful for developing themes or using incomplete themes. -# Defaults to true. -THEME_FALLBACK = - -# TEMPLATES_AUTO_RELOAD -# Specifies whether Flask should check for modifications to templates and reload them automatically. Defaults to true. -TEMPLATES_AUTO_RELOAD = - -# SQLALCHEMY_TRACK_MODIFICATIONS -# Automatically disabled to suppress warnings and save memory. -# You should only enable this if you need it. -# Defaults to false. -SQLALCHEMY_TRACK_MODIFICATIONS = - -# SWAGGER_UI -# Enable the Swagger UI endpoint at /api/v1/ -SWAGGER_UI = - -# UPDATE_CHECK -# Specifies whether or not CTFd will check whether or not there is a new version of CTFd. Defaults True. -UPDATE_CHECK = - -# APPLICATION_ROOT -# Specifies what path CTFd is mounted under. It can be used to run CTFd in a subdirectory. -# Example: /ctfd -APPLICATION_ROOT = - -# SERVER_SENT_EVENTS -# Specifies whether or not to enable the Server-Sent Events based Notifications system. -# Defaults to true -SERVER_SENT_EVENTS = - -# HTML_SANITIZATION -# Specifies whether CTFd should sanitize HTML content -# Defaults to false -HTML_SANITIZATION = - -# SQLALCHEMY_MAX_OVERFLOW -# Specifies the max_overflow setting for SQLAlchemy's Engine -# https://docs.sqlalchemy.org/en/13/core/engines.html#sqlalchemy.create_engine -# https://flask-sqlalchemy.palletsprojects.com/en/2.x/config/#configuration-keys -SQLALCHEMY_MAX_OVERFLOW = - -# SQLALCHEMY_POOL_PRE_PING -# Specifies the pool_pre_ping setting for SQLAlchemy's Engine -# https://docs.sqlalchemy.org/en/13/core/engines.html#sqlalchemy.create_engine -# https://flask-sqlalchemy.palletsprojects.com/en/2.x/config/#configuration-keys -SQLALCHEMY_POOL_PRE_PING = - -# SAFE_MODE -# If SAFE_MODE is enabled, CTFd will not load any plugins which may alleviate issues preventing CTFd from starting -# Defaults to false -SAFE_MODE = - -[oauth] -# OAUTH_CLIENT_ID -# Register an event at https://majorleaguecyber.org/ and use the Client ID here -OAUTH_CLIENT_ID = - -# OAUTH_CLIENT_ID -# Register an event at https://majorleaguecyber.org/ and use the Client Secret here -OAUTH_CLIENT_SECRET = - -[extra] -# The extra section can be used to specify additional values to be loaded into CTFd's configuration diff --git a/CTFd/config.py b/CTFd/config.py deleted file mode 100644 index d2e7222f..00000000 --- a/CTFd/config.py +++ /dev/null @@ -1,266 +0,0 @@ -import configparser -import os -from distutils.util import strtobool -from typing import Union - -from sqlalchemy.engine.url import URL - - -class EnvInterpolation(configparser.BasicInterpolation): - """Interpolation which expands environment variables in values.""" - - def before_get(self, parser, section, option, value, defaults): - value = super().before_get(parser, section, option, value, defaults) - envvar = os.getenv(option) - if value == "" and envvar: - return process_string_var(envvar) - else: - return value - - -def process_string_var(value): - if value == "": - return None - - if value.isdigit(): - return int(value) - elif value.replace(".", "", 1).isdigit(): - return float(value) - - try: - return bool(strtobool(value)) - except ValueError: - return value - - -def process_boolean_str(value): - if type(value) is bool: - return value - - if value is None: - return False - - if value == "": - return None - - return bool(strtobool(value)) - - -def empty_str_cast(value, default=None): - if value == "": - return default - return value - - -def gen_secret_key(): - # Attempt to read the secret from the secret file - # This will fail if the secret has not been written - try: - with open(".ctfd_secret_key", "rb") as secret: - key = secret.read() - except OSError: - key = None - - if not key: - key = os.urandom(64) - # Attempt to write the secret file - # This will fail if the filesystem is read-only - try: - with open(".ctfd_secret_key", "wb") as secret: - secret.write(key) - secret.flush() - except OSError: - pass - return key - - -config_ini = configparser.ConfigParser(interpolation=EnvInterpolation()) -config_ini.optionxform = str # Makes the key value case-insensitive -path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.ini") -config_ini.read(path) - - -# fmt: off -class ServerConfig(object): - SECRET_KEY: str = empty_str_cast(config_ini["server"]["SECRET_KEY"]) \ - or gen_secret_key() - - DATABASE_URL: str = empty_str_cast(config_ini["server"]["DATABASE_URL"]) - if not DATABASE_URL: - if empty_str_cast(config_ini["server"]["DATABASE_HOST"]) is not None: - # construct URL from individual variables - DATABASE_URL = str(URL( - drivername=empty_str_cast(config_ini["server"]["DATABASE_PROTOCOL"]) or "mysql+pymysql", - username=empty_str_cast(config_ini["server"]["DATABASE_USER"]) or "ctfd", - password=empty_str_cast(config_ini["server"]["DATABASE_PASSWORD"]), - host=empty_str_cast(config_ini["server"]["DATABASE_HOST"]), - port=empty_str_cast(config_ini["server"]["DATABASE_PORT"]), - database=empty_str_cast(config_ini["server"]["DATABASE_NAME"]) or "ctfd", - )) - else: - # default to local SQLite DB - DATABASE_URL = f"sqlite:///{os.path.dirname(os.path.abspath(__file__))}/ctfd.db" - - REDIS_URL: str = empty_str_cast(config_ini["server"]["REDIS_URL"]) - - REDIS_HOST: str = empty_str_cast(config_ini["server"]["REDIS_HOST"]) - REDIS_PROTOCOL: str = empty_str_cast(config_ini["server"]["REDIS_PROTOCOL"]) or "redis" - REDIS_USER: str = empty_str_cast(config_ini["server"]["REDIS_USER"]) - REDIS_PASSWORD: str = empty_str_cast(config_ini["server"]["REDIS_PASSWORD"]) - REDIS_PORT: int = empty_str_cast(config_ini["server"]["REDIS_PORT"]) or 6379 - REDIS_DB: int = empty_str_cast(config_ini["server"]["REDIS_DB"]) or 0 - - if REDIS_URL or REDIS_HOST is None: - CACHE_REDIS_URL = REDIS_URL - else: - # construct URL from individual variables - CACHE_REDIS_URL = f"{REDIS_PROTOCOL}://" - if REDIS_USER: - CACHE_REDIS_URL += REDIS_USER - if REDIS_PASSWORD: - CACHE_REDIS_URL += f":{REDIS_PASSWORD}" - CACHE_REDIS_URL += f"@{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}" - - SQLALCHEMY_DATABASE_URI = DATABASE_URL - if CACHE_REDIS_URL: - CACHE_TYPE: str = "redis" - else: - CACHE_TYPE: str = "filesystem" - CACHE_DIR: str = os.path.join( - os.path.dirname(__file__), os.pardir, ".data", "filesystem_cache" - ) - # Override the threshold of cached values on the filesystem. The default is 500. Don't change unless you know what you're doing. - CACHE_THRESHOLD: int = 0 - - # === SECURITY === - SESSION_COOKIE_HTTPONLY: bool = config_ini["security"].getboolean("SESSION_COOKIE_HTTPONLY", fallback=True) - - SESSION_COOKIE_SAMESITE: str = empty_str_cast(config_ini["security"]["SESSION_COOKIE_SAMESITE"]) \ - or "Lax" - - PERMANENT_SESSION_LIFETIME: int = config_ini["security"].getint("PERMANENT_SESSION_LIFETIME") \ - or 604800 - - """ - TRUSTED_PROXIES: - Defines a set of regular expressions used for finding a user's IP address if the CTFd instance - is behind a proxy. If you are running a CTF and users are on the same network as you, you may choose to remove - some proxies from the list. - - CTFd only uses IP addresses for cursory tracking purposes. It is ill-advised to do anything complicated based - solely on IP addresses unless you know what you are doing. - """ - TRUSTED_PROXIES = [ - r"^127\.0\.0\.1$", - # Remove the following proxies if you do not trust the local network - # For example if you are running a CTF on your laptop and the teams are - # all on the same network - r"^::1$", - r"^fc00:", - r"^10\.", - r"^172\.(1[6-9]|2[0-9]|3[0-1])\.", - r"^192\.168\.", - ] - - # === EMAIL === - MAILFROM_ADDR: str = config_ini["email"]["MAILFROM_ADDR"] \ - or "noreply@examplectf.com" - - MAIL_SERVER: str = empty_str_cast(config_ini["email"]["MAIL_SERVER"]) - - MAIL_PORT: int = empty_str_cast(config_ini["email"]["MAIL_PORT"]) - - MAIL_USEAUTH: bool = process_boolean_str(config_ini["email"]["MAIL_USEAUTH"]) - - MAIL_USERNAME: str = empty_str_cast(config_ini["email"]["MAIL_USERNAME"]) - - MAIL_PASSWORD: str = empty_str_cast(config_ini["email"]["MAIL_PASSWORD"]) - - MAIL_TLS: bool = process_boolean_str(config_ini["email"]["MAIL_TLS"]) - - MAIL_SSL: bool = process_boolean_str(config_ini["email"]["MAIL_SSL"]) - - MAILSENDER_ADDR: str = empty_str_cast(config_ini["email"]["MAILSENDER_ADDR"]) - - MAILGUN_API_KEY: str = empty_str_cast(config_ini["email"]["MAILGUN_API_KEY"]) - - MAILGUN_BASE_URL: str = empty_str_cast(config_ini["email"]["MAILGUN_API_KEY"]) - - MAIL_PROVIDER: str = empty_str_cast(config_ini["email"].get("MAIL_PROVIDER")) - - # === LOGS === - LOG_FOLDER: str = empty_str_cast(config_ini["logs"]["LOG_FOLDER"]) \ - or os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs") - - # === UPLOADS === - UPLOAD_PROVIDER: str = empty_str_cast(config_ini["uploads"]["UPLOAD_PROVIDER"]) \ - or "filesystem" - - UPLOAD_FOLDER: str = empty_str_cast(config_ini["uploads"]["UPLOAD_FOLDER"]) \ - or os.path.join(os.path.dirname(os.path.abspath(__file__)), "uploads") - - if UPLOAD_PROVIDER == "s3": - AWS_ACCESS_KEY_ID: str = empty_str_cast(config_ini["uploads"]["AWS_ACCESS_KEY_ID"]) - - AWS_SECRET_ACCESS_KEY: str = empty_str_cast(config_ini["uploads"]["AWS_SECRET_ACCESS_KEY"]) - - AWS_S3_BUCKET: str = empty_str_cast(config_ini["uploads"]["AWS_S3_BUCKET"]) - - AWS_S3_ENDPOINT_URL: str = empty_str_cast(config_ini["uploads"]["AWS_S3_ENDPOINT_URL"]) - - AWS_S3_REGION: str = empty_str_cast(config_ini["uploads"]["AWS_S3_REGION"]) - - # === OPTIONAL === - REVERSE_PROXY: Union[str, bool] = empty_str_cast(config_ini["optional"]["REVERSE_PROXY"], default=False) - - TEMPLATES_AUTO_RELOAD: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["TEMPLATES_AUTO_RELOAD"], default=True)) - - THEME_FALLBACK: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["THEME_FALLBACK"], default=True)) - - SQLALCHEMY_TRACK_MODIFICATIONS: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["SQLALCHEMY_TRACK_MODIFICATIONS"], default=False)) - - SWAGGER_UI: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["SWAGGER_UI"], default=False)) - - SWAGGER_UI_ENDPOINT: str = "/" if SWAGGER_UI else None - - UPDATE_CHECK: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["UPDATE_CHECK"], default=True)) - - APPLICATION_ROOT: str = empty_str_cast(config_ini["optional"]["APPLICATION_ROOT"], default="/") - - SERVER_SENT_EVENTS: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["SERVER_SENT_EVENTS"], default=True)) - - HTML_SANITIZATION: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["HTML_SANITIZATION"], default=False)) - - SAFE_MODE: bool = process_boolean_str(empty_str_cast(config_ini["optional"].get("SAFE_MODE", False), default=False)) - - if DATABASE_URL.startswith("sqlite") is False: - SQLALCHEMY_ENGINE_OPTIONS = { - "max_overflow": int(empty_str_cast(config_ini["optional"]["SQLALCHEMY_MAX_OVERFLOW"], default=20)), # noqa: E131 - "pool_pre_ping": empty_str_cast(config_ini["optional"]["SQLALCHEMY_POOL_PRE_PING"], default=True), # noqa: E131 - } - - # === OAUTH === - OAUTH_CLIENT_ID: str = empty_str_cast(config_ini["oauth"]["OAUTH_CLIENT_ID"]) - OAUTH_CLIENT_SECRET: str = empty_str_cast(config_ini["oauth"]["OAUTH_CLIENT_SECRET"]) -# fmt: on - - -class TestingConfig(ServerConfig): - SECRET_KEY = "AAAAAAAAAAAAAAAAAAAA" - PRESERVE_CONTEXT_ON_EXCEPTION = False - TESTING = True - DEBUG = True - SQLALCHEMY_DATABASE_URI = os.getenv("TESTING_DATABASE_URL") or "sqlite://" - MAIL_SERVER = os.getenv("TESTING_MAIL_SERVER") - SERVER_NAME = "localhost" - UPDATE_CHECK = False - REDIS_URL = None - CACHE_TYPE = "simple" - CACHE_THRESHOLD = 500 - SAFE_MODE = True - - -# Actually initialize ServerConfig to allow us to add more attributes on -Config = ServerConfig() -for k, v in config_ini.items("extra"): - setattr(Config, k, v) diff --git a/CTFd/constants/__init__.py b/CTFd/constants/__init__.py deleted file mode 100644 index 72b09b35..00000000 --- a/CTFd/constants/__init__.py +++ /dev/null @@ -1,66 +0,0 @@ -from enum import Enum - -from flask import current_app - -JS_ENUMS = {} -JINJA_ENUMS = {} - - -class RawEnum(Enum): - """ - This is a customized enum class which should be used with a mixin. - The mixin should define the types of each member. - - For example: - - class Colors(str, RawEnum): - RED = "red" - GREEN = "green" - BLUE = "blue" - """ - - def __str__(self): - return str(self._value_) - - @classmethod - def keys(cls): - return list(cls.__members__.keys()) - - @classmethod - def values(cls): - return list(cls.__members__.values()) - - @classmethod - def test(cls, value): - try: - return bool(cls(value)) - except ValueError: - return False - - -def JSEnum(cls): - """ - This is a decorator used to gather all Enums which should be shared with - the CTFd front end. The JS_Enums dictionary can be taken be a script and - compiled into a JavaScript file for use by frontend assets. JS_Enums - should not be passed directly into Jinja. A JinjaEnum is better for that. - """ - if cls.__name__ not in JS_ENUMS: - JS_ENUMS[cls.__name__] = dict(cls.__members__) - else: - raise KeyError("{} was already defined as a JSEnum".format(cls.__name__)) - return cls - - -def JinjaEnum(cls): - """ - This is a decorator used to inject the decorated Enum into Jinja globals - which allows you to access it from the front end. If you need to access - an Enum from JS, a better tool to use is the JSEnum decorator. - """ - if cls.__name__ not in current_app.jinja_env.globals: - current_app.jinja_env.globals[cls.__name__] = cls - JINJA_ENUMS[cls.__name__] = cls - else: - raise KeyError("{} was already defined as a JinjaEnum".format(cls.__name__)) - return cls diff --git a/CTFd/constants/assets.py b/CTFd/constants/assets.py deleted file mode 100644 index e84f1cb0..00000000 --- a/CTFd/constants/assets.py +++ /dev/null @@ -1,44 +0,0 @@ -import os - -from flask import current_app, url_for - -from CTFd.utils import get_asset_json -from CTFd.utils.config import ctf_theme -from CTFd.utils.helpers import markup - - -class _AssetsWrapper: - def manifest(self): - theme = ctf_theme() - manifest = os.path.join( - current_app.root_path, "themes", theme, "static", "manifest.json" - ) - return get_asset_json(path=manifest) - - def js(self, asset_key): - asset = self.manifest()[asset_key] - entry = asset["file"] - imports = asset.get("imports", []) - html = "" - for i in imports: - # TODO: Needs a better recursive solution - i = self.manifest()[i]["file"] - url = url_for("views.themes_beta", path=i) - html += f'' - url = url_for("views.themes_beta", path=entry) - html += f'' - return markup(html) - - def css(self, asset_key): - asset = self.manifest()[asset_key] - entry = asset["file"] - url = url_for("views.themes_beta", path=entry) - return markup(f'') - - def file(self, asset_key): - asset = self.manifest()[asset_key] - entry = asset["file"] - return url_for("views.themes_beta", path=entry) - - -Assets = _AssetsWrapper() diff --git a/CTFd/constants/config.py b/CTFd/constants/config.py deleted file mode 100644 index 46ef6560..00000000 --- a/CTFd/constants/config.py +++ /dev/null @@ -1,99 +0,0 @@ -import json - -from flask import url_for - -from CTFd.constants import JinjaEnum, RawEnum -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" - - -class _ConfigsWrapper: - def __getattr__(self, attr): - return get_config(attr) - - @property - def ctf_name(self): - return get_config("ctf_name", default="CTFd") - - @property - def ctf_small_icon(self): - icon = get_config("ctf_small_icon") - if icon: - return url_for("views.files", path=icon) - return url_for("views.themes", path="img/favicon.ico") - - @property - def theme_header(self): - from CTFd.utils.helpers import markup - - return markup(get_config("theme_header", default="")) - - @property - def theme_footer(self): - from CTFd.utils.helpers import markup - - return markup(get_config("theme_footer", default="")) - - @property - def theme_settings(self): - try: - return json.loads(get_config("theme_settings", default="null")) - except json.JSONDecodeError: - return {"error": "invalid theme_settings"} - - @property - def tos_or_privacy(self): - tos = bool(get_config("tos_url") or get_config("tos_text")) - privacy = bool(get_config("privacy_url") or get_config("privacy_text")) - return tos or privacy - - @property - def tos_link(self): - return get_config("tos_url", default=url_for("views.tos")) - - @property - def privacy_link(self): - return get_config("privacy_url", default=url_for("views.privacy")) - - -Configs = _ConfigsWrapper() diff --git a/CTFd/constants/languages.py b/CTFd/constants/languages.py deleted file mode 100644 index fdc36da5..00000000 --- a/CTFd/constants/languages.py +++ /dev/null @@ -1,18 +0,0 @@ -from CTFd.constants import RawEnum - - -class Languages(str, RawEnum): - ENGLISH = "en" - GERMAN = "de" - POLISH = "pl" - - -LANGUAGE_NAMES = { - "en": "English", - "de": "Deutsch", - "pl": "Polski", -} - -SELECT_LANGUAGE_LIST = [("", "")] + [ - (str(lang), LANGUAGE_NAMES.get(str(lang))) for lang in Languages -] diff --git a/CTFd/constants/plugins.py b/CTFd/constants/plugins.py deleted file mode 100644 index 31887469..00000000 --- a/CTFd/constants/plugins.py +++ /dev/null @@ -1,54 +0,0 @@ -from flask import current_app - -from CTFd.plugins import get_admin_plugin_menu_bar, get_user_page_menu_bar -from CTFd.utils.helpers import markup -from CTFd.utils.plugins import get_registered_scripts, get_registered_stylesheets - - -class _PluginWrapper: - @property - def scripts(self): - application_root = current_app.config.get("APPLICATION_ROOT") - subdir = application_root != "/" - scripts = [] - for script in get_registered_scripts(): - if script.startswith("http"): - scripts.append(f'') - elif subdir: - scripts.append( - f'' - ) - else: - scripts.append(f'') - return markup("\n".join(scripts)) - - @property - def styles(self): - application_root = current_app.config.get("APPLICATION_ROOT") - subdir = application_root != "/" - _styles = [] - for stylesheet in get_registered_stylesheets(): - if stylesheet.startswith("http"): - _styles.append( - f'' - ) - elif subdir: - _styles.append( - f'' - ) - else: - _styles.append( - f'' - ) - return markup("\n".join(_styles)) - - @property - def user_menu_pages(self): - return get_user_page_menu_bar() - - @property - def admin_menu_pages(self): - return get_admin_plugin_menu_bar() - - -Plugins = _PluginWrapper() diff --git a/CTFd/constants/sessions.py b/CTFd/constants/sessions.py deleted file mode 100644 index a5417a55..00000000 --- a/CTFd/constants/sessions.py +++ /dev/null @@ -1,18 +0,0 @@ -from flask import session - - -class _SessionWrapper: - @property - def id(self): - return session.get("id", 0) - - @property - def nonce(self): - return session.get("nonce") - - @property - def hash(self): - return session.get("hash") - - -Session = _SessionWrapper() diff --git a/CTFd/constants/static.py b/CTFd/constants/static.py deleted file mode 100644 index 93a380f7..00000000 --- a/CTFd/constants/static.py +++ /dev/null @@ -1,14 +0,0 @@ -from CTFd.constants import JinjaEnum, RawEnum - - -@JinjaEnum -class CacheKeys(str, RawEnum): - PUBLIC_SCOREBOARD_TABLE = "public_scoreboard_table" - - -# Placeholder object. Not used, just imported to force initialization of any Enums here -class _StaticsWrapper: - pass - - -Static = _StaticsWrapper() diff --git a/CTFd/constants/teams.py b/CTFd/constants/teams.py deleted file mode 100644 index 16893a9e..00000000 --- a/CTFd/constants/teams.py +++ /dev/null @@ -1,43 +0,0 @@ -from collections import namedtuple - -TeamAttrs = namedtuple( - "TeamAttrs", - [ - "id", - "oauth_id", - "name", - "email", - "secret", - "website", - "affiliation", - "country", - "bracket", - "hidden", - "banned", - "captain_id", - "created", - ], -) - - -class _TeamAttrsWrapper: - def __getattr__(self, attr): - from CTFd.utils.user import get_current_team_attrs - - attrs = get_current_team_attrs() - return getattr(attrs, attr, None) - - @property - def place(self): - from CTFd.utils.user import get_team_place - - return get_team_place(team_id=self.id) - - @property - def score(self): - from CTFd.utils.user import get_team_score - - return get_team_score(team_id=self.id) - - -Team = _TeamAttrsWrapper() diff --git a/CTFd/constants/themes.py b/CTFd/constants/themes.py deleted file mode 100644 index 46c92858..00000000 --- a/CTFd/constants/themes.py +++ /dev/null @@ -1,2 +0,0 @@ -ADMIN_THEME = "admin" -DEFAULT_THEME = "core" diff --git a/CTFd/constants/users.py b/CTFd/constants/users.py deleted file mode 100644 index ab9e5fe6..00000000 --- a/CTFd/constants/users.py +++ /dev/null @@ -1,46 +0,0 @@ -from collections import namedtuple - -UserAttrs = namedtuple( - "UserAttrs", - [ - "id", - "oauth_id", - "name", - "email", - "type", - "secret", - "website", - "affiliation", - "country", - "bracket", - "hidden", - "banned", - "verified", - "language", - "team_id", - "created", - ], -) - - -class _UserAttrsWrapper: - def __getattr__(self, attr): - from CTFd.utils.user import get_current_user_attrs - - attrs = get_current_user_attrs() - return getattr(attrs, attr, None) - - @property - def place(self): - from CTFd.utils.user import get_user_place - - return get_user_place(user_id=self.id) - - @property - def score(self): - from CTFd.utils.user import get_user_score - - return get_user_score(user_id=self.id) - - -User = _UserAttrsWrapper() diff --git a/CTFd/errors.py b/CTFd/errors.py deleted file mode 100644 index d2c3c88f..00000000 --- a/CTFd/errors.py +++ /dev/null @@ -1,20 +0,0 @@ -import jinja2.exceptions -from flask import render_template -from werkzeug.exceptions import InternalServerError - - -def render_error(error): - if ( - isinstance(error, InternalServerError) - and error.description == InternalServerError.description - ): - error.description = "An Internal Server Error has occurred" - try: - return ( - render_template( - "errors/{}.html".format(error.code), error=error.description, - ), - error.code, - ) - except jinja2.exceptions.TemplateNotFound: - return error.get_response() diff --git a/CTFd/events/__init__.py b/CTFd/events/__init__.py deleted file mode 100644 index fcfac6db..00000000 --- a/CTFd/events/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -from flask import Blueprint, Response, current_app, stream_with_context - -from CTFd.utils import get_app_config -from CTFd.utils.decorators import authed_only, ratelimit - -events = Blueprint("events", __name__) - - -@events.route("/events") -@authed_only -@ratelimit(method="GET", limit=150, interval=60) -def subscribe(): - @stream_with_context - def gen(): - for event in current_app.events_manager.subscribe(): - yield str(event) - - enabled = get_app_config("SERVER_SENT_EVENTS") - if enabled is False: - return ("", 204) - - return Response(gen(), mimetype="text/event-stream") diff --git a/CTFd/exceptions/__init__.py b/CTFd/exceptions/__init__.py deleted file mode 100644 index 9788f12f..00000000 --- a/CTFd/exceptions/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -class UserNotFoundException(Exception): - pass - - -class UserTokenExpiredException(Exception): - pass - - -class TeamTokenExpiredException(Exception): - pass - - -class TeamTokenInvalidException(Exception): - pass diff --git a/CTFd/forms/__init__.py b/CTFd/forms/__init__.py deleted file mode 100644 index b6e29ce9..00000000 --- a/CTFd/forms/__init__.py +++ /dev/null @@ -1,49 +0,0 @@ -from wtforms import Form -from wtforms.csrf.core import CSRF - - -class CTFdCSRF(CSRF): - def generate_csrf_token(self, csrf_token_field): - from flask import session - - return session.get("nonce") - - -class BaseForm(Form): - class Meta: - csrf = True - csrf_class = CTFdCSRF - csrf_field_name = "nonce" - - -class _FormsWrapper: - pass - - -Forms = _FormsWrapper() - -from CTFd.forms import auth # noqa: I001 isort:skip -from CTFd.forms import self # noqa: I001 isort:skip -from CTFd.forms import teams # noqa: I001 isort:skip -from CTFd.forms import setup # noqa: I001 isort:skip -from CTFd.forms import submissions # noqa: I001 isort:skip -from CTFd.forms import users # noqa: I001 isort:skip -from CTFd.forms import challenges # noqa: I001 isort:skip -from CTFd.forms import notifications # noqa: I001 isort:skip -from CTFd.forms import config # noqa: I001 isort:skip -from CTFd.forms import pages # noqa: I001 isort:skip -from CTFd.forms import awards # noqa: I001 isort:skip -from CTFd.forms import email # noqa: I001 isort:skip - -Forms.auth = auth -Forms.self = self -Forms.teams = teams -Forms.setup = setup -Forms.submissions = submissions -Forms.users = users -Forms.challenges = challenges -Forms.notifications = notifications -Forms.config = config -Forms.pages = pages -Forms.awards = awards -Forms.email = email diff --git a/CTFd/forms/auth.py b/CTFd/forms/auth.py deleted file mode 100644 index c64f59cb..00000000 --- a/CTFd/forms/auth.py +++ /dev/null @@ -1,62 +0,0 @@ -from flask_babel import lazy_gettext as _l -from wtforms import PasswordField, StringField -from wtforms.fields.html5 import EmailField -from wtforms.validators import InputRequired - -from CTFd.forms import BaseForm -from CTFd.forms.fields import SubmitField -from CTFd.forms.users import ( - attach_custom_user_fields, - attach_registration_code_field, - build_custom_user_fields, - build_registration_code_field, -) - - -def RegistrationForm(*args, **kwargs): - class _RegistrationForm(BaseForm): - name = StringField( - _l("User Name"), validators=[InputRequired()], render_kw={"autofocus": True} - ) - 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) - - attach_custom_user_fields(_RegistrationForm) - attach_registration_code_field(_RegistrationForm) - - return _RegistrationForm(*args, **kwargs) - - -class LoginForm(BaseForm): - name = StringField( - _l("User Name or Email"), - validators=[InputRequired()], - render_kw={"autofocus": True}, - ) - password = PasswordField(_l("Password"), validators=[InputRequired()]) - submit = SubmitField(_l("Submit")) - - -class ConfirmForm(BaseForm): - submit = SubmitField(_l("Resend Confirmation Email")) - - -class ResetPasswordRequestForm(BaseForm): - email = EmailField( - _l("Email"), validators=[InputRequired()], render_kw={"autofocus": True} - ) - submit = SubmitField(_l("Submit")) - - -class ResetPasswordForm(BaseForm): - password = PasswordField( - _l("Password"), validators=[InputRequired()], render_kw={"autofocus": True} - ) - submit = SubmitField(_l("Submit")) diff --git a/CTFd/forms/awards.py b/CTFd/forms/awards.py deleted file mode 100644 index 32819fae..00000000 --- a/CTFd/forms/awards.py +++ /dev/null @@ -1,30 +0,0 @@ -from wtforms import RadioField, StringField, TextAreaField -from wtforms.fields.html5 import IntegerField - -from CTFd.forms import BaseForm -from CTFd.forms.fields import SubmitField - - -class AwardCreationForm(BaseForm): - name = StringField("Name") - value = IntegerField("Value") - category = StringField("Category") - description = TextAreaField("Description") - submit = SubmitField("Create") - icon = RadioField( - "Icon", - choices=[ - ("", "None"), - ("shield", "Shield"), - ("bug", "Bug"), - ("crown", "Crown"), - ("crosshairs", "Crosshairs"), - ("ban", "Ban"), - ("lightning", "Lightning"), - ("skull", "Skull"), - ("brain", "Brain"), - ("code", "Code"), - ("cowboy", "Cowboy"), - ("angry", "Angry"), - ], - ) diff --git a/CTFd/forms/challenges.py b/CTFd/forms/challenges.py deleted file mode 100644 index e5b5a4ca..00000000 --- a/CTFd/forms/challenges.py +++ /dev/null @@ -1,30 +0,0 @@ -from wtforms import MultipleFileField, SelectField, StringField -from wtforms.validators import InputRequired - -from CTFd.forms import BaseForm -from CTFd.forms.fields import SubmitField - - -class ChallengeSearchForm(BaseForm): - field = SelectField( - "Search Field", - choices=[ - ("name", "Name"), - ("id", "ID"), - ("category", "Category"), - ("type", "Type"), - ], - default="name", - validators=[InputRequired()], - ) - q = StringField("Parameter", validators=[InputRequired()]) - submit = SubmitField("Search") - - -class ChallengeFilesUploadForm(BaseForm): - file = MultipleFileField( - "Upload Files", - description="Attach multiple files using Control+Click or Cmd+Click.", - validators=[InputRequired()], - ) - submit = SubmitField("Upload") diff --git a/CTFd/forms/config.py b/CTFd/forms/config.py deleted file mode 100644 index da6e00ec..00000000 --- a/CTFd/forms/config.py +++ /dev/null @@ -1,110 +0,0 @@ -from wtforms import BooleanField, FileField, SelectField, StringField, TextAreaField -from wtforms.fields.html5 import IntegerField, URLField -from wtforms.widgets.html5 import NumberInput - -from CTFd.forms import BaseForm -from CTFd.forms.fields import SubmitField -from CTFd.utils.csv import get_dumpable_tables - - -class ResetInstanceForm(BaseForm): - accounts = BooleanField( - "Accounts", - description="Deletes all user and team accounts and their associated information", - ) - submissions = BooleanField( - "Submissions", - description="Deletes all records that accounts gained points or took an action", - ) - challenges = BooleanField( - "Challenges", description="Deletes all challenges and associated data" - ) - pages = BooleanField( - "Pages", description="Deletes all pages and their associated files" - ) - notifications = BooleanField( - "Notifications", description="Deletes all notifications" - ) - submit = SubmitField("Reset CTF") - - -class AccountSettingsForm(BaseForm): - domain_whitelist = StringField( - "Account Email Whitelist", - description="Comma-seperated email domains which users can register under (e.g. ctfd.io, gmail.com, yahoo.com)", - ) - team_creation = SelectField( - "Team Creation", - description="Control whether users can create their own teams (Teams mode only)", - choices=[("true", "Enabled"), ("false", "Disabled")], - default="true", - ) - team_size = IntegerField( - widget=NumberInput(min=0), - description="Amount of users per team (Teams mode only)", - ) - num_teams = IntegerField( - "Total Number of Teams", - widget=NumberInput(min=0), - description="Max number of teams (Teams mode only)", - ) - verify_emails = SelectField( - "Verify Emails", - description="Control whether users must confirm their email addresses before playing", - choices=[("true", "Enabled"), ("false", "Disabled")], - default="false", - ) - team_disbanding = SelectField( - "Team Disbanding", - description="Control whether team captains are allowed to disband their own teams", - choices=[ - ("inactive_only", "Enabled for Inactive Teams"), - ("disabled", "Disabled"), - ], - default="inactive_only", - ) - name_changes = SelectField( - "Name Changes", - description="Control whether users and teams can change their names", - choices=[("true", "Enabled"), ("false", "Disabled")], - default="true", - ) - incorrect_submissions_per_min = IntegerField( - "Incorrect Submissions per Minute", - widget=NumberInput(min=1), - description="Amount of submissions allowed per minute for flag bruteforce protection (default: 10)", - ) - - submit = SubmitField("Update") - - -class ExportCSVForm(BaseForm): - table = SelectField("Database Table", choices=get_dumpable_tables()) - submit = SubmitField("Download CSV") - - -class ImportCSVForm(BaseForm): - csv_type = SelectField( - "CSV Type", - choices=[("users", "Users"), ("teams", "Teams"), ("challenges", "Challenges")], - description="Type of CSV data", - ) - csv_file = FileField("CSV File", description="CSV file contents") - - -class LegalSettingsForm(BaseForm): - tos_url = URLField( - "Terms of Service URL", - description="External URL to a Terms of Service document hosted elsewhere", - ) - tos_text = TextAreaField( - "Terms of Service", description="Text shown on the Terms of Service page", - ) - privacy_url = URLField( - "Privacy Policy URL", - description="External URL to a Privacy Policy document hosted elsewhere", - ) - privacy_text = TextAreaField( - "Privacy Policy", description="Text shown on the Privacy Policy page", - ) - submit = SubmitField("Update") diff --git a/CTFd/forms/email.py b/CTFd/forms/email.py deleted file mode 100644 index 889cfac0..00000000 --- a/CTFd/forms/email.py +++ /dev/null @@ -1,10 +0,0 @@ -from wtforms import TextAreaField -from wtforms.validators import InputRequired - -from CTFd.forms import BaseForm -from CTFd.forms.fields import SubmitField - - -class SendEmailForm(BaseForm): - text = TextAreaField("Message", validators=[InputRequired()]) - submit = SubmitField("Send") diff --git a/CTFd/forms/fields.py b/CTFd/forms/fields.py deleted file mode 100644 index 4c0a5bc4..00000000 --- a/CTFd/forms/fields.py +++ /dev/null @@ -1,17 +0,0 @@ -from wtforms import SubmitField as _SubmitField - - -class SubmitField(_SubmitField): - """ - This custom SubmitField exists because wtforms is dumb. - - See https://github.com/wtforms/wtforms/issues/205, https://github.com/wtforms/wtforms/issues/36 - The .submit() handler in JS will break if the form has an input with the name or id of "submit" so submit fields need to be changed. - """ - - def __init__(self, *args, **kwargs): - name = kwargs.pop("name", "_submit") - super().__init__(*args, **kwargs) - if self.name == "submit" or name: - self.id = name - self.name = name diff --git a/CTFd/forms/notifications.py b/CTFd/forms/notifications.py deleted file mode 100644 index c590b6da..00000000 --- a/CTFd/forms/notifications.py +++ /dev/null @@ -1,26 +0,0 @@ -from wtforms import BooleanField, RadioField, StringField, TextAreaField -from wtforms.validators import InputRequired - -from CTFd.forms import BaseForm -from CTFd.forms.fields import SubmitField - - -class NotificationForm(BaseForm): - title = StringField("Title", description="Notification title") - content = TextAreaField( - "Content", - description="Notification contents. Can consist of HTML and/or Markdown.", - ) - type = RadioField( - "Notification Type", - choices=[("toast", "Toast"), ("alert", "Alert"), ("background", "Background")], - default="toast", - description="What type of notification users receive", - validators=[InputRequired()], - ) - sound = BooleanField( - "Play Sound", - default=True, - description="Play sound for users when they receive the notification", - ) - submit = SubmitField("Submit") diff --git a/CTFd/forms/pages.py b/CTFd/forms/pages.py deleted file mode 100644 index 3069b593..00000000 --- a/CTFd/forms/pages.py +++ /dev/null @@ -1,41 +0,0 @@ -from wtforms import ( - BooleanField, - HiddenField, - MultipleFileField, - SelectField, - StringField, - TextAreaField, -) -from wtforms.validators import InputRequired - -from CTFd.forms import BaseForm - - -class PageEditForm(BaseForm): - title = StringField( - "Title", description="This is the title shown on the navigation bar" - ) - route = StringField( - "Route", - description="This is the URL route that your page will be at (e.g. /page). You can also enter links to link to that page.", - ) - draft = BooleanField("Draft") - hidden = BooleanField("Hidden") - auth_required = BooleanField("Authentication Required") - content = TextAreaField("Content") - format = SelectField( - "Format", - choices=[("markdown", "Markdown"), ("html", "HTML")], - default="markdown", - validators=[InputRequired()], - description="The markup format used to render the page", - ) - - -class PageFilesUploadForm(BaseForm): - file = MultipleFileField( - "Upload Files", - description="Attach multiple files using Control+Click or Cmd+Click.", - validators=[InputRequired()], - ) - type = HiddenField("Page Type", default="page", validators=[InputRequired()]) diff --git a/CTFd/forms/self.py b/CTFd/forms/self.py deleted file mode 100644 index c527138b..00000000 --- a/CTFd/forms/self.py +++ /dev/null @@ -1,53 +0,0 @@ -from flask import session -from flask_babel import lazy_gettext as _l -from wtforms import PasswordField, SelectField, StringField -from wtforms.fields.html5 import DateField, URLField - -from CTFd.constants.languages import SELECT_LANGUAGE_LIST -from CTFd.forms import BaseForm -from CTFd.forms.fields import SubmitField -from CTFd.forms.users import attach_custom_user_fields, build_custom_user_fields -from CTFd.utils.countries import SELECT_COUNTRIES_LIST -from CTFd.utils.user import get_current_user - - -def SettingsForm(*args, **kwargs): - class _SettingsForm(BaseForm): - name = StringField(_l("User Name")) - email = StringField(_l("Email")) - language = SelectField(_l("Language"), choices=SELECT_LANGUAGE_LIST) - password = PasswordField(_l("Password")) - confirm = PasswordField(_l("Current Password")) - affiliation = StringField(_l("Affiliation")) - website = URLField(_l("Website")) - country = SelectField(_l("Country"), choices=SELECT_COUNTRIES_LIST) - submit = SubmitField(_l("Submit")) - - @property - def extra(self): - 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"]}, - ) - - @staticmethod - def get_field_kwargs(): - user = get_current_user() - field_kwargs = {"editable": True} - if user.filled_all_required_fields is False: - # Show all fields - field_kwargs = {} - return field_kwargs - - field_kwargs = _SettingsForm.get_field_kwargs() - attach_custom_user_fields(_SettingsForm, **field_kwargs) - - return _SettingsForm(*args, **kwargs) - - -class TokensForm(BaseForm): - expiration = DateField(_l("Expiration")) - submit = SubmitField(_l("Generate")) diff --git a/CTFd/forms/setup.py b/CTFd/forms/setup.py deleted file mode 100644 index a993a58b..00000000 --- a/CTFd/forms/setup.py +++ /dev/null @@ -1,90 +0,0 @@ -from flask_babel import lazy_gettext as _l -from wtforms import ( - FileField, - HiddenField, - PasswordField, - RadioField, - SelectField, - StringField, - TextAreaField, -) -from wtforms.fields.html5 import EmailField -from wtforms.validators import InputRequired - -from CTFd.constants.themes import DEFAULT_THEME -from CTFd.forms import BaseForm -from CTFd.forms.fields import SubmitField -from CTFd.utils.config import get_themes - - -class SetupForm(BaseForm): - ctf_name = StringField( - _l("Event Name"), description=_l("The name of your CTF event/workshop") - ) - ctf_description = TextAreaField( - _l("Event Description"), description=_l("Description for the CTF") - ) - user_mode = RadioField( - _l("User Mode"), - choices=[("teams", _l("Team Mode")), ("users", _l("User Mode"))], - default="teams", - description=_l( - "Controls whether users join together in teams to play (Team Mode) or play as themselves (User Mode)" - ), - validators=[InputRequired()], - ) - - name = StringField( - _l("Admin Username"), - description=_l("Your username for the administration account"), - validators=[InputRequired()], - ) - email = EmailField( - _l("Admin Email"), - description=_l("Your email address for the administration account"), - validators=[InputRequired()], - ) - password = PasswordField( - _l("Admin Password"), - description=_l("Your password for the administration account"), - validators=[InputRequired()], - ) - - ctf_logo = FileField( - _l("Logo"), - description=_l( - "Logo to use for the website instead of a CTF name. Used as the home page button. Optional." - ), - ) - ctf_banner = FileField( - _l("Banner"), description=_l("Banner to use for the homepage. Optional.") - ) - ctf_small_icon = FileField( - _l("Small Icon"), - description=_l( - "favicon used in user's browsers. Only PNGs accepted. Must be 32x32px. Optional." - ), - ) - ctf_theme = SelectField( - _l("Theme"), - description=_l("CTFd Theme to use. Can be changed later."), - choices=list(zip(get_themes(), get_themes())), - default=DEFAULT_THEME, - validators=[InputRequired()], - ) - theme_color = HiddenField( - _l("Theme Color"), - description=_l( - "Color used by theme to control aesthetics. Requires theme support. Optional." - ), - ) - - start = StringField( - _l("Start Time"), - description=_l("Time when your CTF is scheduled to start. Optional."), - ) - end = StringField( - _l("End Time"), - description=_l("Time when your CTF is scheduled to end. Optional."), - ) - submit = SubmitField(_l("Finish")) diff --git a/CTFd/forms/submissions.py b/CTFd/forms/submissions.py deleted file mode 100644 index 5b8faebb..00000000 --- a/CTFd/forms/submissions.py +++ /dev/null @@ -1,22 +0,0 @@ -from wtforms import SelectField, StringField -from wtforms.validators import InputRequired - -from CTFd.forms import BaseForm -from CTFd.forms.fields import SubmitField - - -class SubmissionSearchForm(BaseForm): - field = SelectField( - "Search Field", - choices=[ - ("provided", "Provided"), - ("id", "ID"), - ("account_id", "Account ID"), - ("challenge_id", "Challenge ID"), - ("challenge_name", "Challenge Name"), - ], - default="provided", - validators=[InputRequired()], - ) - q = StringField("Parameter", validators=[InputRequired()]) - submit = SubmitField("Search") diff --git a/CTFd/forms/teams.py b/CTFd/forms/teams.py deleted file mode 100644 index 0ba1620b..00000000 --- a/CTFd/forms/teams.py +++ /dev/null @@ -1,258 +0,0 @@ -from flask_babel import lazy_gettext as _l -from wtforms import BooleanField, PasswordField, SelectField, StringField -from wtforms.fields.html5 import EmailField, URLField -from wtforms.validators import InputRequired - -from CTFd.forms import BaseForm -from CTFd.forms.fields import SubmitField -from CTFd.models import TeamFieldEntries, TeamFields -from CTFd.utils.countries import SELECT_COUNTRIES_LIST -from CTFd.utils.user import get_current_team - - -def build_custom_team_fields( - form_cls, - include_entries=False, - fields_kwargs=None, - field_entries_kwargs=None, - blacklisted_items=("affiliation", "website"), -): - if fields_kwargs is None: - fields_kwargs = {} - if field_entries_kwargs is None: - field_entries_kwargs = {} - - fields = [] - new_fields = TeamFields.query.filter_by(**fields_kwargs).all() - user_fields = {} - - # Only include preexisting values if asked - if include_entries is True: - for f in TeamFieldEntries.query.filter_by(**field_entries_kwargs).all(): - user_fields[f.field_id] = f.value - - for field in new_fields: - if field.name.lower() in blacklisted_items: - continue - - form_field = getattr(form_cls, f"fields[{field.id}]") - - # Add the field_type to the field so we know how to render it - form_field.field_type = field.field_type - - # Only include preexisting values if asked - if include_entries is True: - initial = user_fields.get(field.id, "") - form_field.data = initial - if form_field.render_kw: - form_field.render_kw["data-initial"] = initial - else: - form_field.render_kw = {"data-initial": initial} - - fields.append(form_field) - return fields - - -def attach_custom_team_fields(form_cls, **kwargs): - new_fields = TeamFields.query.filter_by(**kwargs).all() - for field in new_fields: - validators = [] - if field.required: - validators.append(InputRequired()) - - if field.field_type == "text": - input_field = StringField( - field.name, description=field.description, validators=validators - ) - elif field.field_type == "boolean": - input_field = BooleanField( - field.name, description=field.description, validators=validators - ) - - setattr(form_cls, f"fields[{field.id}]", input_field) - - -class TeamJoinForm(BaseForm): - name = StringField(_l("Team Name"), validators=[InputRequired()]) - password = PasswordField(_l("Team Password"), validators=[InputRequired()]) - submit = SubmitField(_l("Join")) - - -def TeamRegisterForm(*args, **kwargs): - class _TeamRegisterForm(BaseForm): - name = StringField(_l("Team Name"), validators=[InputRequired()]) - password = PasswordField(_l("Team Password"), validators=[InputRequired()]) - submit = SubmitField(_l("Create")) - - @property - def extra(self): - return build_custom_team_fields( - self, include_entries=False, blacklisted_items=() - ) - - attach_custom_team_fields(_TeamRegisterForm) - return _TeamRegisterForm(*args, **kwargs) - - -def TeamSettingsForm(*args, **kwargs): - class _TeamSettingsForm(BaseForm): - name = StringField( - _l("Team Name"), - description=_l("Your team's public name shown to other competitors"), - ) - password = PasswordField( - _l("New Team Password"), description=_l("Set a new team join password") - ) - confirm = PasswordField( - _l("Confirm Current Team Password"), - description=_l( - "Provide your current team password (or your password) to update your team's password" - ), - ) - affiliation = StringField( - _l("Affiliation"), - description=_l( - "Your team's affiliation publicly shown to other competitors" - ), - ) - website = URLField( - _l("Website"), - description=_l("Your team's website publicly shown to other competitors"), - ) - country = SelectField( - _l("Country"), - choices=SELECT_COUNTRIES_LIST, - description=_l("Your team's country publicly shown to other competitors"), - ) - submit = SubmitField(_l("Submit")) - - @property - def extra(self): - fields_kwargs = _TeamSettingsForm.get_field_kwargs() - return build_custom_team_fields( - self, - include_entries=True, - fields_kwargs=fields_kwargs, - field_entries_kwargs={"team_id": self.obj.id}, - ) - - def get_field_kwargs(): - team = get_current_team() - field_kwargs = {"editable": True} - if team.filled_all_required_fields is False: - # Show all fields - field_kwargs = {} - return field_kwargs - - def __init__(self, *args, **kwargs): - """ - Custom init to persist the obj parameter to the rest of the form - """ - super().__init__(*args, **kwargs) - obj = kwargs.get("obj") - if obj: - self.obj = obj - - field_kwargs = _TeamSettingsForm.get_field_kwargs() - attach_custom_team_fields(_TeamSettingsForm, **field_kwargs) - - return _TeamSettingsForm(*args, **kwargs) - - -class TeamCaptainForm(BaseForm): - # Choices are populated dynamically at form creation time - captain_id = SelectField( - _l("Team Captain"), choices=[], validators=[InputRequired()] - ) - submit = SubmitField("Submit") - - -class TeamSearchForm(BaseForm): - field = SelectField( - "Search Field", - choices=[ - ("name", "Name"), - ("id", "ID"), - ("affiliation", "Affiliation"), - ("website", "Website"), - ], - default="name", - validators=[InputRequired()], - ) - q = StringField("Parameter", validators=[InputRequired()]) - submit = SubmitField("Search") - - -class PublicTeamSearchForm(BaseForm): - field = SelectField( - _l("Search Field"), - choices=[ - ("name", _l("Name")), - ("affiliation", _l("Affiliation")), - ("website", _l("Website")), - ], - default="name", - validators=[InputRequired()], - ) - q = StringField(_l("Parameter"), validators=[InputRequired()]) - submit = SubmitField(_l("Search")) - - -class TeamBaseForm(BaseForm): - name = StringField(_l("Team Name"), validators=[InputRequired()]) - email = EmailField(_l("Email")) - password = PasswordField(_l("Password")) - website = URLField(_l("Website")) - affiliation = StringField(_l("Affiliation")) - country = SelectField(_l("Country"), choices=SELECT_COUNTRIES_LIST) - hidden = BooleanField(_l("Hidden")) - banned = BooleanField(_l("Banned")) - submit = SubmitField(_l("Submit")) - - -def TeamCreateForm(*args, **kwargs): - class _TeamCreateForm(TeamBaseForm): - pass - - @property - def extra(self): - return build_custom_team_fields(self, include_entries=False) - - attach_custom_team_fields(_TeamCreateForm) - - return _TeamCreateForm(*args, **kwargs) - - -def TeamEditForm(*args, **kwargs): - class _TeamEditForm(TeamBaseForm): - pass - - @property - def extra(self): - return build_custom_team_fields( - self, - include_entries=True, - fields_kwargs=None, - field_entries_kwargs={"team_id": self.obj.id}, - ) - - def __init__(self, *args, **kwargs): - """ - Custom init to persist the obj parameter to the rest of the form - """ - super().__init__(*args, **kwargs) - obj = kwargs.get("obj") - if obj: - self.obj = obj - - attach_custom_team_fields(_TeamEditForm) - - return _TeamEditForm(*args, **kwargs) - - -class TeamInviteForm(BaseForm): - link = URLField(_l("Invite Link")) - - -class TeamInviteJoinForm(BaseForm): - submit = SubmitField(_l("Join")) diff --git a/CTFd/forms/users.py b/CTFd/forms/users.py deleted file mode 100644 index 587a8f54..00000000 --- a/CTFd/forms/users.py +++ /dev/null @@ -1,204 +0,0 @@ -from flask_babel import lazy_gettext as _l -from wtforms import BooleanField, PasswordField, SelectField, StringField -from wtforms.fields.html5 import EmailField -from wtforms.validators import InputRequired - -from CTFd.constants.config import Configs -from CTFd.constants.languages import SELECT_LANGUAGE_LIST -from CTFd.forms import BaseForm -from CTFd.forms.fields import SubmitField -from CTFd.models import UserFieldEntries, UserFields -from CTFd.utils.countries import SELECT_COUNTRIES_LIST - - -def build_custom_user_fields( - form_cls, - include_entries=False, - fields_kwargs=None, - field_entries_kwargs=None, - blacklisted_items=("affiliation", "website"), -): - """ - Function used to reinject values back into forms for accessing by themes - """ - if fields_kwargs is None: - fields_kwargs = {} - if field_entries_kwargs is None: - field_entries_kwargs = {} - - fields = [] - new_fields = UserFields.query.filter_by(**fields_kwargs).all() - user_fields = {} - - # Only include preexisting values if asked - if include_entries is True: - for f in UserFieldEntries.query.filter_by(**field_entries_kwargs).all(): - user_fields[f.field_id] = f.value - - for field in new_fields: - if field.name.lower() in blacklisted_items: - continue - - form_field = getattr(form_cls, f"fields[{field.id}]") - - # Add the field_type to the field so we know how to render it - form_field.field_type = field.field_type - - # Only include preexisting values if asked - if include_entries is True: - initial = user_fields.get(field.id, "") - form_field.data = initial - if form_field.render_kw: - form_field.render_kw["data-initial"] = initial - else: - form_field.render_kw = {"data-initial": initial} - - fields.append(form_field) - return fields - - -def attach_custom_user_fields(form_cls, **kwargs): - """ - Function used to attach form fields to wtforms. - Not really a great solution but is approved by wtforms. - - https://wtforms.readthedocs.io/en/2.3.x/specific_problems/#dynamic-form-composition - """ - new_fields = UserFields.query.filter_by(**kwargs).all() - for field in new_fields: - validators = [] - if field.required: - validators.append(InputRequired()) - - if field.field_type == "text": - input_field = StringField( - field.name, description=field.description, validators=validators - ) - elif field.field_type == "boolean": - input_field = BooleanField( - field.name, description=field.description, validators=validators - ) - - setattr(form_cls, f"fields[{field.id}]", input_field) - - -def build_registration_code_field(form_cls): - """ - Build the appropriate field so we can render it via the extra property. - Add field_type so Jinja knows how to render it. - """ - if Configs.registration_code: - field = getattr(form_cls, "registration_code") # noqa B009 - field.field_type = "text" - return [field] - else: - return [] - - -def attach_registration_code_field(form_cls): - """ - If we have a registration code required, we attach it to the form similar - to attach_custom_user_fields - """ - if Configs.registration_code: - setattr( # noqa B010 - form_cls, - "registration_code", - StringField( - "Registration Code", - description="Registration code required to create account", - validators=[InputRequired()], - ), - ) - - -class UserSearchForm(BaseForm): - field = SelectField( - "Search Field", - choices=[ - ("name", "Name"), - ("id", "ID"), - ("email", "Email"), - ("affiliation", "Affiliation"), - ("website", "Website"), - ("ip", "IP Address"), - ], - default="name", - validators=[InputRequired()], - ) - q = StringField("Parameter", validators=[InputRequired()]) - submit = SubmitField("Search") - - -class PublicUserSearchForm(BaseForm): - field = SelectField( - _l("Search Field"), - choices=[ - ("name", _l("Name")), - ("affiliation", _l("Affiliation")), - ("website", _l("Website")), - ], - default="name", - validators=[InputRequired()], - ) - q = StringField( - _l("Parameter"), - description=_l("Search for matching users"), - validators=[InputRequired()], - ) - submit = SubmitField(_l("Search")) - - -class UserBaseForm(BaseForm): - name = StringField("User Name", validators=[InputRequired()]) - email = EmailField("Email", validators=[InputRequired()]) - language = SelectField(_l("Language"), choices=SELECT_LANGUAGE_LIST) - password = PasswordField("Password") - website = StringField("Website") - affiliation = StringField("Affiliation") - country = SelectField("Country", choices=SELECT_COUNTRIES_LIST) - type = SelectField("Type", choices=[("user", "User"), ("admin", "Admin")]) - verified = BooleanField("Verified") - hidden = BooleanField("Hidden") - banned = BooleanField("Banned") - submit = SubmitField("Submit") - - -def UserEditForm(*args, **kwargs): - class _UserEditForm(UserBaseForm): - pass - - @property - def extra(self): - return build_custom_user_fields( - self, - include_entries=True, - fields_kwargs=None, - field_entries_kwargs={"user_id": self.obj.id}, - ) - - def __init__(self, *args, **kwargs): - """ - Custom init to persist the obj parameter to the rest of the form - """ - super().__init__(*args, **kwargs) - obj = kwargs.get("obj") - if obj: - self.obj = obj - - attach_custom_user_fields(_UserEditForm) - - return _UserEditForm(*args, **kwargs) - - -def UserCreateForm(*args, **kwargs): - class _UserCreateForm(UserBaseForm): - notify = BooleanField("Email account credentials to user", default=True) - - @property - def extra(self): - return build_custom_user_fields(self, include_entries=False) - - attach_custom_user_fields(_UserCreateForm) - - return _UserCreateForm(*args, **kwargs) diff --git a/CTFd/logs/.gitkeep b/CTFd/logs/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/CTFd/models/__init__.py b/CTFd/models/__init__.py deleted file mode 100644 index 713bfc3f..00000000 --- a/CTFd/models/__init__.py +++ /dev/null @@ -1,1024 +0,0 @@ -import datetime -from collections import defaultdict - -from flask_marshmallow import Marshmallow -from flask_sqlalchemy import SQLAlchemy -from sqlalchemy.ext.compiler import compiles -from sqlalchemy.ext.hybrid import hybrid_property -from sqlalchemy.orm import column_property, validates - -from CTFd.cache import cache - -db = SQLAlchemy() -ma = Marshmallow() - - -def get_class_by_tablename(tablename): - """Return class reference mapped to table. - https://stackoverflow.com/a/23754464 - - :param tablename: String with name of table. - :return: Class reference or None. - """ - for c in db.Model._decl_class_registry.values(): - if hasattr(c, "__tablename__") and c.__tablename__ == tablename: - return c - return None - - -@compiles(db.DateTime, "mysql") -def compile_datetime_mysql(_type, _compiler, **kw): - """ - This decorator makes the default db.DateTime class always enable fsp to enable millisecond precision - https://dev.mysql.com/doc/refman/5.7/en/fractional-seconds.html - https://docs.sqlalchemy.org/en/14/core/custom_types.html#overriding-type-compilation - """ - return "DATETIME(6)" - - -class Notifications(db.Model): - __tablename__ = "notifications" - id = db.Column(db.Integer, primary_key=True) - title = db.Column(db.Text) - content = db.Column(db.Text) - date = db.Column(db.DateTime, default=datetime.datetime.utcnow) - user_id = db.Column(db.Integer, db.ForeignKey("users.id")) - team_id = db.Column(db.Integer, db.ForeignKey("teams.id")) - - user = db.relationship("Users", foreign_keys="Notifications.user_id", lazy="select") - team = db.relationship("Teams", foreign_keys="Notifications.team_id", lazy="select") - - @property - def html(self): - from CTFd.utils.config.pages import build_markdown - from CTFd.utils.helpers import markup - - return markup(build_markdown(self.content)) - - def __init__(self, *args, **kwargs): - super(Notifications, self).__init__(**kwargs) - - -class Pages(db.Model): - __tablename__ = "pages" - id = db.Column(db.Integer, primary_key=True) - title = db.Column(db.String(80)) - route = db.Column(db.String(128), unique=True) - content = db.Column(db.Text) - draft = db.Column(db.Boolean) - hidden = db.Column(db.Boolean) - auth_required = db.Column(db.Boolean) - format = db.Column(db.String(80), default="markdown") - # TODO: Use hidden attribute - - files = db.relationship("PageFiles", backref="page") - - @property - def html(self): - from CTFd.utils.config.pages import build_html, build_markdown - - if self.format == "markdown": - return build_markdown(self.content) - elif self.format == "html": - return build_html(self.content) - else: - return build_markdown(self.content) - - def __init__(self, *args, **kwargs): - super(Pages, self).__init__(**kwargs) - - def __repr__(self): - return "".format(self.route) - - -class Challenges(db.Model): - __tablename__ = "challenges" - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(80)) - description = db.Column(db.Text) - connection_info = db.Column(db.Text) - next_id = db.Column(db.Integer, db.ForeignKey("challenges.id", ondelete="SET NULL")) - max_attempts = db.Column(db.Integer, default=0) - value = db.Column(db.Integer) - category = db.Column(db.String(80)) - type = db.Column(db.String(80)) - state = db.Column(db.String(80), nullable=False, default="visible") - requirements = db.Column(db.JSON) - - files = db.relationship("ChallengeFiles", backref="challenge") - tags = db.relationship("Tags", backref="challenge") - hints = db.relationship("Hints", backref="challenge") - flags = db.relationship("Flags", backref="challenge") - comments = db.relationship("ChallengeComments", backref="challenge") - topics = db.relationship("ChallengeTopics", backref="challenge") - - class alt_defaultdict(defaultdict): - """ - This slightly modified defaultdict is intended to allow SQLAlchemy to - not fail when querying Challenges that contain a missing challenge type. - - e.g. Challenges.query.all() should not fail if `type` is `a_missing_type` - """ - - def __missing__(self, key): - return self["standard"] - - __mapper_args__ = { - "polymorphic_identity": "standard", - "polymorphic_on": type, - "_polymorphic_map": alt_defaultdict(), - } - - @property - def html(self): - from CTFd.utils.config.pages import build_markdown - from CTFd.utils.helpers import markup - - return markup(build_markdown(self.description)) - - @property - def plugin_class(self): - from CTFd.plugins.challenges import get_chal_class - - return get_chal_class(self.type) - - def __init__(self, *args, **kwargs): - super(Challenges, self).__init__(**kwargs) - - def __repr__(self): - return "" % self.name - - -class Hints(db.Model): - __tablename__ = "hints" - id = db.Column(db.Integer, primary_key=True) - type = db.Column(db.String(80), default="standard") - challenge_id = db.Column( - db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE") - ) - content = db.Column(db.Text) - cost = db.Column(db.Integer, default=0) - requirements = db.Column(db.JSON) - - __mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type} - - @property - def name(self): - return "Hint {id}".format(id=self.id) - - @property - def category(self): - return self.__tablename__ - - @property - def description(self): - return "Hint for {name}".format(name=self.challenge.name) - - @property - def html(self): - from CTFd.utils.config.pages import build_markdown - from CTFd.utils.helpers import markup - - return markup(build_markdown(self.content)) - - def __init__(self, *args, **kwargs): - super(Hints, self).__init__(**kwargs) - - def __repr__(self): - return "" % self.content - - -class Awards(db.Model): - __tablename__ = "awards" - id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) - team_id = db.Column(db.Integer, db.ForeignKey("teams.id", ondelete="CASCADE")) - type = db.Column(db.String(80), default="standard") - name = db.Column(db.String(80)) - description = db.Column(db.Text) - date = db.Column(db.DateTime, default=datetime.datetime.utcnow) - value = db.Column(db.Integer) - category = db.Column(db.String(80)) - icon = db.Column(db.Text) - requirements = db.Column(db.JSON) - - user = db.relationship("Users", foreign_keys="Awards.user_id", lazy="select") - team = db.relationship("Teams", foreign_keys="Awards.team_id", lazy="select") - - __mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type} - - @hybrid_property - def account_id(self): - from CTFd.utils import get_config - - user_mode = get_config("user_mode") - if user_mode == "teams": - return self.team_id - elif user_mode == "users": - return self.user_id - - def __init__(self, *args, **kwargs): - super(Awards, self).__init__(**kwargs) - - def __repr__(self): - return "" % self.name - - -class Tags(db.Model): - __tablename__ = "tags" - id = db.Column(db.Integer, primary_key=True) - challenge_id = db.Column( - db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE") - ) - value = db.Column(db.String(80)) - - def __init__(self, *args, **kwargs): - super(Tags, self).__init__(**kwargs) - - -class Topics(db.Model): - __tablename__ = "topics" - id = db.Column(db.Integer, primary_key=True) - value = db.Column(db.String(255), unique=True) - - def __init__(self, *args, **kwargs): - super(Topics, self).__init__(**kwargs) - - -class ChallengeTopics(db.Model): - __tablename__ = "challenge_topics" - id = db.Column(db.Integer, primary_key=True) - challenge_id = db.Column( - db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE") - ) - topic_id = db.Column(db.Integer, db.ForeignKey("topics.id", ondelete="CASCADE")) - - topic = db.relationship( - "Topics", foreign_keys="ChallengeTopics.topic_id", lazy="select" - ) - - def __init__(self, *args, **kwargs): - super(ChallengeTopics, self).__init__(**kwargs) - - -class 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) - - __mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type} - - def __init__(self, *args, **kwargs): - super(Files, self).__init__(**kwargs) - - def __repr__(self): - return "".format( - type=self.type, location=self.location - ) - - -class ChallengeFiles(Files): - __mapper_args__ = {"polymorphic_identity": "challenge"} - challenge_id = db.Column( - db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE") - ) - - def __init__(self, *args, **kwargs): - super(ChallengeFiles, self).__init__(**kwargs) - - -class PageFiles(Files): - __mapper_args__ = {"polymorphic_identity": "page"} - page_id = db.Column(db.Integer, db.ForeignKey("pages.id")) - - def __init__(self, *args, **kwargs): - super(PageFiles, self).__init__(**kwargs) - - -class Flags(db.Model): - __tablename__ = "flags" - id = db.Column(db.Integer, primary_key=True) - challenge_id = db.Column( - db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE") - ) - type = db.Column(db.String(80)) - content = db.Column(db.Text) - data = db.Column(db.Text) - - __mapper_args__ = {"polymorphic_on": type} - - def __init__(self, *args, **kwargs): - super(Flags, self).__init__(**kwargs) - - def __repr__(self): - return "".format(self.content, self.challenge_id) - - -class Users(db.Model): - __tablename__ = "users" - __table_args__ = (db.UniqueConstraint("id", "oauth_id"), {}) - # Core attributes - id = db.Column(db.Integer, primary_key=True) - oauth_id = db.Column(db.Integer, unique=True) - # User names are not constrained to be unique to allow for official/unofficial teams. - name = db.Column(db.String(128)) - password = db.Column(db.String(128)) - email = db.Column(db.String(128), unique=True) - type = db.Column(db.String(80)) - secret = db.Column(db.String(128)) - - # Supplementary attributes - website = db.Column(db.String(128)) - affiliation = db.Column(db.String(128)) - country = db.Column(db.String(32)) - bracket = db.Column(db.String(32)) - 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) - - # Relationship for Teams - team_id = db.Column(db.Integer, db.ForeignKey("teams.id")) - - field_entries = db.relationship( - "UserFieldEntries", foreign_keys="UserFieldEntries.user_id", lazy="joined" - ) - - created = db.Column(db.DateTime, default=datetime.datetime.utcnow) - - __mapper_args__ = {"polymorphic_identity": "user", "polymorphic_on": type} - - def __init__(self, **kwargs): - super(Users, self).__init__(**kwargs) - - @validates("password") - def validate_password(self, key, plaintext): - from CTFd.utils.crypto import hash_password - - return hash_password(str(plaintext)) - - @hybrid_property - def account_id(self): - from CTFd.utils import get_config - - user_mode = get_config("user_mode") - if user_mode == "teams": - return self.team_id - elif user_mode == "users": - return self.id - - @hybrid_property - def account(self): - from CTFd.utils import get_config - - user_mode = get_config("user_mode") - if user_mode == "teams": - return self.team - elif user_mode == "users": - return self - - @property - def fields(self): - return self.get_fields(admin=False) - - @property - def solves(self): - return self.get_solves(admin=False) - - @property - def fails(self): - return self.get_fails(admin=False) - - @property - def awards(self): - return self.get_awards(admin=False) - - @property - def score(self): - return self.get_score(admin=False) - - @property - def place(self): - from CTFd.utils.config.visibility import scores_visible - - if scores_visible(): - return self.get_place(admin=False) - else: - return None - - @property - def filled_all_required_fields(self): - required_user_fields = { - u.id - for u in UserFields.query.with_entities(UserFields.id) - .filter_by(required=True) - .all() - } - submitted_user_fields = { - u.field_id - for u in UserFieldEntries.query.with_entities(UserFieldEntries.field_id) - .filter_by(user_id=self.id) - .all() - } - return required_user_fields.issubset(submitted_user_fields) - - def get_fields(self, admin=False): - if admin: - return self.field_entries - - return [ - entry for entry in self.field_entries if entry.field.public and entry.value - ] - - def get_solves(self, admin=False): - from CTFd.utils import get_config - - solves = Solves.query.filter_by(user_id=self.id).order_by(Solves.date.desc()) - freeze = get_config("freeze") - if freeze and admin is False: - dt = datetime.datetime.utcfromtimestamp(freeze) - solves = solves.filter(Solves.date < dt) - return solves.all() - - def get_fails(self, admin=False): - from CTFd.utils import get_config - - fails = Fails.query.filter_by(user_id=self.id).order_by(Fails.date.desc()) - freeze = get_config("freeze") - if freeze and admin is False: - dt = datetime.datetime.utcfromtimestamp(freeze) - fails = fails.filter(Fails.date < dt) - return fails.all() - - def get_awards(self, admin=False): - from CTFd.utils import get_config - - awards = Awards.query.filter_by(user_id=self.id).order_by(Awards.date.desc()) - freeze = get_config("freeze") - if freeze and admin is False: - dt = datetime.datetime.utcfromtimestamp(freeze) - awards = awards.filter(Awards.date < dt) - return awards.all() - - @cache.memoize() - def get_score(self, admin=False): - score = db.func.sum(Challenges.value).label("score") - user = ( - db.session.query(Solves.user_id, score) - .join(Users, Solves.user_id == Users.id) - .join(Challenges, Solves.challenge_id == Challenges.id) - .filter(Users.id == self.id) - ) - - award_score = db.func.sum(Awards.value).label("award_score") - award = db.session.query(award_score).filter_by(user_id=self.id) - - if not admin: - freeze = Configs.query.filter_by(key="freeze").first() - if freeze and freeze.value: - freeze = int(freeze.value) - freeze = datetime.datetime.utcfromtimestamp(freeze) - user = user.filter(Solves.date < freeze) - award = award.filter(Awards.date < freeze) - - user = user.group_by(Solves.user_id).first() - award = award.first() - - if user and award: - return int(user.score or 0) + int(award.award_score or 0) - elif user: - return int(user.score or 0) - elif award: - return int(award.award_score or 0) - else: - return 0 - - @cache.memoize() - def get_place(self, admin=False, numeric=False): - """ - This method is generally a clone of CTFd.scoreboard.get_standings. - The point being that models.py must be self-reliant and have little - to no imports within the CTFd application as importing from the - application itself will result in a circular import. - """ - from CTFd.utils.scores import get_user_standings # noqa: I001 - from CTFd.utils.humanize.numbers import ordinalize - - standings = get_user_standings(admin=admin) - - for i, user in enumerate(standings): - if user.user_id == self.id: - n = i + 1 - if numeric: - return n - return ordinalize(n) - else: - return None - - -class Admins(Users): - __tablename__ = "admins" - __mapper_args__ = {"polymorphic_identity": "admin"} - - -class Teams(db.Model): - __tablename__ = "teams" - __table_args__ = (db.UniqueConstraint("id", "oauth_id"), {}) - # Core attributes - id = db.Column(db.Integer, primary_key=True) - oauth_id = db.Column(db.Integer, unique=True) - # Team names are not constrained to be unique to allow for official/unofficial teams. - name = db.Column(db.String(128)) - email = db.Column(db.String(128), unique=True) - password = db.Column(db.String(128)) - secret = db.Column(db.String(128)) - - members = db.relationship( - "Users", backref="team", foreign_keys="Users.team_id", lazy="joined" - ) - - # Supplementary attributes - website = db.Column(db.String(128)) - affiliation = db.Column(db.String(128)) - country = db.Column(db.String(32)) - bracket = db.Column(db.String(32)) - hidden = db.Column(db.Boolean, default=False) - banned = db.Column(db.Boolean, default=False) - - # Relationship for Users - captain_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL")) - captain = db.relationship("Users", foreign_keys=[captain_id]) - - field_entries = db.relationship( - "TeamFieldEntries", foreign_keys="TeamFieldEntries.team_id", lazy="joined" - ) - - created = db.Column(db.DateTime, default=datetime.datetime.utcnow) - - def __init__(self, **kwargs): - super(Teams, self).__init__(**kwargs) - - @validates("password") - def validate_password(self, key, plaintext): - from CTFd.utils.crypto import hash_password - - return hash_password(str(plaintext)) - - @property - def fields(self): - return self.get_fields(admin=False) - - @property - def solves(self): - return self.get_solves(admin=False) - - @property - def fails(self): - return self.get_fails(admin=False) - - @property - def awards(self): - return self.get_awards(admin=False) - - @property - def score(self): - return self.get_score(admin=False) - - @property - def place(self): - from CTFd.utils.config.visibility import scores_visible - - if scores_visible(): - return self.get_place(admin=False) - else: - return None - - @property - def filled_all_required_fields(self): - required_team_fields = { - u.id - for u in TeamFields.query.with_entities(TeamFields.id) - .filter_by(required=True) - .all() - } - submitted_team_fields = { - u.field_id - for u in TeamFieldEntries.query.with_entities(TeamFieldEntries.field_id) - .filter_by(team_id=self.id) - .all() - } - return required_team_fields.issubset(submitted_team_fields) - - def get_fields(self, admin=False): - if admin: - return self.field_entries - - return [ - entry for entry in self.field_entries if entry.field.public and entry.value - ] - - def get_invite_code(self): - from flask import current_app # noqa: I001 - from CTFd.utils.security.signing import serialize, hmac - - 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 - - invite_object = { - "id": self.id, - "v": hmac(str(self.id), secret=verification_secret), - } - code = serialize(data=invite_object, secret=secret_key) - return code - - @classmethod - def load_invite_code(cls, code): - from flask import current_app # noqa: I001 - from CTFd.utils.security.signing import ( - unserialize, - hmac, - BadTimeSignature, - BadSignature, - ) - from CTFd.exceptions import TeamTokenExpiredException, TeamTokenInvalidException - - secret_key = current_app.config["SECRET_KEY"] - if isinstance(secret_key, str): - secret_key = secret_key.encode("utf-8") - - # Unserialize the invite code - try: - # Links expire after 1 day - invite_object = unserialize(code, max_age=86400) - except BadTimeSignature: - raise TeamTokenExpiredException - except BadSignature: - raise TeamTokenInvalidException - - # Load the team by the ID in the invite - team_id = invite_object["id"] - team = cls.query.filter_by(id=team_id).first_or_404() - - # Create the team specific secret - team_password_key = team.password.encode("utf-8") - verification_secret = secret_key + team_password_key - - # Verify the team verficiation code - verified = hmac(str(team.id), secret=verification_secret) == invite_object["v"] - if verified is False: - raise TeamTokenInvalidException - return team - - def get_solves(self, admin=False): - from CTFd.utils import get_config - - member_ids = [member.id for member in self.members] - - solves = Solves.query.filter(Solves.user_id.in_(member_ids)).order_by( - Solves.date.desc() - ) - - freeze = get_config("freeze") - if freeze and admin is False: - dt = datetime.datetime.utcfromtimestamp(freeze) - solves = solves.filter(Solves.date < dt) - - return solves.all() - - def get_fails(self, admin=False): - from CTFd.utils import get_config - - member_ids = [member.id for member in self.members] - - fails = Fails.query.filter(Fails.user_id.in_(member_ids)).order_by( - Fails.date.desc() - ) - - freeze = get_config("freeze") - if freeze and admin is False: - dt = datetime.datetime.utcfromtimestamp(freeze) - fails = fails.filter(Fails.date < dt) - - return fails.all() - - def get_awards(self, admin=False): - from CTFd.utils import get_config - - member_ids = [member.id for member in self.members] - - awards = Awards.query.filter(Awards.user_id.in_(member_ids)).order_by( - Awards.date.desc() - ) - - freeze = get_config("freeze") - if freeze and admin is False: - dt = datetime.datetime.utcfromtimestamp(freeze) - awards = awards.filter(Awards.date < dt) - - return awards.all() - - @cache.memoize() - def get_score(self, admin=False): - score = 0 - for member in self.members: - score += member.get_score(admin=admin) - return score - - @cache.memoize() - def get_place(self, admin=False, numeric=False): - """ - This method is generally a clone of CTFd.scoreboard.get_standings. - The point being that models.py must be self-reliant and have little - to no imports within the CTFd application as importing from the - application itself will result in a circular import. - """ - from CTFd.utils.scores import get_team_standings # noqa: I001 - from CTFd.utils.humanize.numbers import ordinalize - - standings = get_team_standings(admin=admin) - - for i, team in enumerate(standings): - if team.team_id == self.id: - n = i + 1 - if numeric: - return n - return ordinalize(n) - else: - return None - - -class Submissions(db.Model): - __tablename__ = "submissions" - id = db.Column(db.Integer, primary_key=True) - challenge_id = db.Column( - db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE") - ) - user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) - team_id = db.Column(db.Integer, db.ForeignKey("teams.id", ondelete="CASCADE")) - ip = db.Column(db.String(46)) - provided = db.Column(db.Text) - type = db.Column(db.String(32)) - date = db.Column(db.DateTime, default=datetime.datetime.utcnow) - - # Relationships - user = db.relationship("Users", foreign_keys="Submissions.user_id", lazy="select") - team = db.relationship("Teams", foreign_keys="Submissions.team_id", lazy="select") - challenge = db.relationship( - "Challenges", foreign_keys="Submissions.challenge_id", lazy="select" - ) - - __mapper_args__ = {"polymorphic_on": type} - - @hybrid_property - def account_id(self): - from CTFd.utils import get_config - - user_mode = get_config("user_mode") - if user_mode == "teams": - return self.team_id - elif user_mode == "users": - return self.user_id - - @hybrid_property - def account(self): - from CTFd.utils import get_config - - user_mode = get_config("user_mode") - if user_mode == "teams": - return self.team - elif user_mode == "users": - return self.user - - @staticmethod - def get_child(type): - child_classes = { - x.polymorphic_identity: x.class_ - for x in Submissions.__mapper__.self_and_descendants - } - return child_classes[type] - - def __repr__(self): - return f"" - - -class Solves(Submissions): - __tablename__ = "solves" - __table_args__ = ( - db.UniqueConstraint("challenge_id", "user_id"), - db.UniqueConstraint("challenge_id", "team_id"), - {}, - ) - id = db.Column( - None, db.ForeignKey("submissions.id", ondelete="CASCADE"), primary_key=True - ) - challenge_id = column_property( - db.Column(db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE")), - Submissions.challenge_id, - ) - user_id = column_property( - db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")), - Submissions.user_id, - ) - team_id = column_property( - db.Column(db.Integer, db.ForeignKey("teams.id", ondelete="CASCADE")), - Submissions.team_id, - ) - - user = db.relationship("Users", foreign_keys="Solves.user_id", lazy="select") - team = db.relationship("Teams", foreign_keys="Solves.team_id", lazy="select") - challenge = db.relationship( - "Challenges", foreign_keys="Solves.challenge_id", lazy="select" - ) - - __mapper_args__ = {"polymorphic_identity": "correct"} - - -class Fails(Submissions): - __mapper_args__ = {"polymorphic_identity": "incorrect"} - - -class Unlocks(db.Model): - __tablename__ = "unlocks" - id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) - team_id = db.Column(db.Integer, db.ForeignKey("teams.id", ondelete="CASCADE")) - target = db.Column(db.Integer) - date = db.Column(db.DateTime, default=datetime.datetime.utcnow) - type = db.Column(db.String(32)) - - __mapper_args__ = {"polymorphic_on": type} - - @hybrid_property - def account_id(self): - from CTFd.utils import get_config - - user_mode = get_config("user_mode") - if user_mode == "teams": - return self.team_id - elif user_mode == "users": - return self.user_id - - def __repr__(self): - return "" % self.id - - -class HintUnlocks(Unlocks): - __mapper_args__ = {"polymorphic_identity": "hints"} - - -class 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)) - user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) - date = db.Column(db.DateTime, default=datetime.datetime.utcnow) - - user = db.relationship("Users", foreign_keys="Tracking.user_id", lazy="select") - - __mapper_args__ = {"polymorphic_on": type} - - def __init__(self, *args, **kwargs): - super(Tracking, self).__init__(**kwargs) - - def __repr__(self): - return "" % self.ip - - -class Configs(db.Model): - __tablename__ = "config" - id = db.Column(db.Integer, primary_key=True) - key = db.Column(db.Text) - value = db.Column(db.Text) - - def __init__(self, *args, **kwargs): - super(Configs, self).__init__(**kwargs) - - -class Tokens(db.Model): - __tablename__ = "tokens" - id = db.Column(db.Integer, primary_key=True) - type = db.Column(db.String(32)) - user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) - created = db.Column(db.DateTime, default=datetime.datetime.utcnow) - expiration = db.Column( - db.DateTime, - default=lambda: datetime.datetime.utcnow() + datetime.timedelta(days=30), - ) - value = db.Column(db.String(128), unique=True) - - user = db.relationship("Users", foreign_keys="Tokens.user_id", lazy="select") - - __mapper_args__ = {"polymorphic_on": type} - - def __init__(self, *args, **kwargs): - super(Tokens, self).__init__(**kwargs) - - def __repr__(self): - return "" % self.id - - -class UserTokens(Tokens): - __mapper_args__ = {"polymorphic_identity": "user"} - - -class Comments(db.Model): - __tablename__ = "comments" - id = db.Column(db.Integer, primary_key=True) - type = db.Column(db.String(80), default="standard") - content = db.Column(db.Text) - date = db.Column(db.DateTime, default=datetime.datetime.utcnow) - author_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) - author = db.relationship("Users", foreign_keys="Comments.author_id", lazy="select") - - @property - def html(self): - from CTFd.utils.config.pages import build_markdown - from CTFd.utils.helpers import markup - - return markup(build_markdown(self.content, sanitize=True)) - - __mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type} - - -class ChallengeComments(Comments): - __mapper_args__ = {"polymorphic_identity": "challenge"} - challenge_id = db.Column( - db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE") - ) - - -class UserComments(Comments): - __mapper_args__ = {"polymorphic_identity": "user"} - user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) - - -class TeamComments(Comments): - __mapper_args__ = {"polymorphic_identity": "team"} - team_id = db.Column(db.Integer, db.ForeignKey("teams.id", ondelete="CASCADE")) - - -class PageComments(Comments): - __mapper_args__ = {"polymorphic_identity": "page"} - page_id = db.Column(db.Integer, db.ForeignKey("pages.id", ondelete="CASCADE")) - - -class Fields(db.Model): - __tablename__ = "fields" - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.Text) - type = db.Column(db.String(80), default="standard") - field_type = db.Column(db.String(80)) - description = db.Column(db.Text) - required = db.Column(db.Boolean, default=False) - public = db.Column(db.Boolean, default=False) - editable = db.Column(db.Boolean, default=False) - - __mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type} - - -class UserFields(Fields): - __mapper_args__ = {"polymorphic_identity": "user"} - - -class TeamFields(Fields): - __mapper_args__ = {"polymorphic_identity": "team"} - - -class FieldEntries(db.Model): - __tablename__ = "field_entries" - id = db.Column(db.Integer, primary_key=True) - type = db.Column(db.String(80), default="standard") - value = db.Column(db.JSON) - field_id = db.Column(db.Integer, db.ForeignKey("fields.id", ondelete="CASCADE")) - - field = db.relationship( - "Fields", foreign_keys="FieldEntries.field_id", lazy="joined" - ) - - __mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type} - - @hybrid_property - def name(self): - return self.field.name - - @hybrid_property - def description(self): - return self.field.description - - -class UserFieldEntries(FieldEntries): - __mapper_args__ = {"polymorphic_identity": "user"} - user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) - user = db.relationship("Users", foreign_keys="UserFieldEntries.user_id") - - -class TeamFieldEntries(FieldEntries): - __mapper_args__ = {"polymorphic_identity": "team"} - team_id = db.Column(db.Integer, db.ForeignKey("teams.id", ondelete="CASCADE")) - team = db.relationship("Teams", foreign_keys="TeamFieldEntries.team_id") diff --git a/CTFd/plugins/__init__.py b/CTFd/plugins/__init__.py deleted file mode 100644 index 30ac1f11..00000000 --- a/CTFd/plugins/__init__.py +++ /dev/null @@ -1,209 +0,0 @@ -import glob -import importlib -import os -from collections import namedtuple - -from flask import current_app as app -from flask import send_file, send_from_directory, url_for - -from CTFd.utils.config.pages import get_pages -from CTFd.utils.decorators import admins_only as admins_only_wrapper -from CTFd.utils.plugins import override_template as utils_override_template -from CTFd.utils.plugins import ( - register_admin_script as utils_register_admin_plugin_script, -) -from CTFd.utils.plugins import ( - register_admin_stylesheet as utils_register_admin_plugin_stylesheet, -) -from CTFd.utils.plugins import register_script as utils_register_plugin_script -from CTFd.utils.plugins import register_stylesheet as utils_register_plugin_stylesheet - -Menu = namedtuple("Menu", ["title", "route"]) - - -def register_plugin_assets_directory(app, base_path, admins_only=False, endpoint=None): - """ - Registers a directory to serve assets - - :param app: A CTFd application - :param string base_path: The path to the directory - :param boolean admins_only: Whether or not the assets served out of the directory should be accessible to the public - :return: - """ - base_path = base_path.strip("/") - if endpoint is None: - endpoint = base_path.replace("/", ".") - - def assets_handler(path): - return send_from_directory(base_path, path) - - rule = "/" + base_path + "/" - app.add_url_rule(rule=rule, endpoint=endpoint, view_func=assets_handler) - - -def register_plugin_asset(app, asset_path, admins_only=False, endpoint=None): - """ - Registers an file path to be served by CTFd - - :param app: A CTFd application - :param string asset_path: The path to the asset file - :param boolean admins_only: Whether or not this file should be accessible to the public - :return: - """ - asset_path = asset_path.strip("/") - if endpoint is None: - endpoint = asset_path.replace("/", ".") - - def asset_handler(): - return send_file(asset_path) - - if admins_only: - asset_handler = admins_only_wrapper(asset_handler) - rule = "/" + asset_path - app.add_url_rule(rule=rule, endpoint=endpoint, view_func=asset_handler) - - -def override_template(*args, **kwargs): - """ - Overrides a template with the provided html content. - - e.g. override_template('scoreboard.html', '

scores

') - """ - utils_override_template(*args, **kwargs) - - -def register_plugin_script(*args, **kwargs): - """ - Adds a given script to the base.html template which all pages inherit from - """ - utils_register_plugin_script(*args, **kwargs) - - -def register_plugin_stylesheet(*args, **kwargs): - """ - Adds a given stylesheet to the base.html template which all pages inherit from. - """ - utils_register_plugin_stylesheet(*args, **kwargs) - - -def register_admin_plugin_script(*args, **kwargs): - """ - Adds a given script to the base.html of the admin theme which all admin pages inherit from - :param args: - :param kwargs: - :return: - """ - utils_register_admin_plugin_script(*args, **kwargs) - - -def register_admin_plugin_stylesheet(*args, **kwargs): - """ - Adds a given stylesheet to the base.html of the admin theme which all admin pages inherit from - :param args: - :param kwargs: - :return: - """ - utils_register_admin_plugin_stylesheet(*args, **kwargs) - - -def register_admin_plugin_menu_bar(title, route): - """ - Registers links on the Admin Panel menubar/navbar - - :param name: A string that is shown on the navbar HTML - :param route: A string that is the href used by the link - :return: - """ - am = Menu(title=title, route=route) - app.admin_plugin_menu_bar.append(am) - - -def get_admin_plugin_menu_bar(): - """ - Access the list used to store the plugin menu bar - - :return: Returns a list of Menu namedtuples. They have name, and route attributes. - """ - return app.admin_plugin_menu_bar - - -def register_user_page_menu_bar(title, route): - """ - Registers links on the User side menubar/navbar - - :param name: A string that is shown on the navbar HTML - :param route: A string that is the href used by the link - :return: - """ - p = Menu(title=title, route=route) - app.plugin_menu_bar.append(p) - - -def get_user_page_menu_bar(): - """ - Access the list used to store the user page menu bar - - :return: Returns a list of Menu namedtuples. They have name, and route attributes. - """ - pages = [] - for p in get_pages() + app.plugin_menu_bar: - if p.route.startswith("http"): - route = p.route - else: - route = url_for("views.static_html", route=p.route) - pages.append(Menu(title=p.title, route=route)) - return pages - - -def bypass_csrf_protection(f): - """ - Decorator that allows a route to bypass the need for a CSRF nonce on POST requests. - - This should be considered beta and may change in future versions. - - :param f: A function that needs to bypass CSRF protection - :return: Returns a function with the _bypass_csrf attribute set which tells CTFd to not require CSRF protection. - """ - f._bypass_csrf = True - return f - - -def get_plugin_names(): - modules = sorted(glob.glob(app.plugins_dir + "/*")) - blacklist = {"__pycache__"} - plugins = [] - for module in modules: - module_name = os.path.basename(module) - if os.path.isdir(module) and module_name not in blacklist: - plugins.append(module_name) - return plugins - - -def init_plugins(app): - """ - Searches for the load function in modules in the CTFd/plugins folder. This function is called with the current CTFd - app as a parameter. This allows CTFd plugins to modify CTFd's behavior. - - :param app: A CTFd application - :return: - """ - app.admin_plugin_scripts = [] - app.admin_plugin_stylesheets = [] - app.plugin_scripts = [] - app.plugin_stylesheets = [] - - app.admin_plugin_menu_bar = [] - app.plugin_menu_bar = [] - app.plugins_dir = os.path.dirname(__file__) - - if app.config.get("SAFE_MODE", False) is False: - for plugin in get_plugin_names(): - module = "." + plugin - module = importlib.import_module(module, package="CTFd.plugins") - module.load(app) - print(" * Loaded module, %s" % module) - else: - print("SAFE_MODE is enabled. Skipping plugin loading.") - - app.jinja_env.globals.update(get_admin_plugin_menu_bar=get_admin_plugin_menu_bar) - app.jinja_env.globals.update(get_user_page_menu_bar=get_user_page_menu_bar) diff --git a/CTFd/plugins/challenges/__init__.py b/CTFd/plugins/challenges/__init__.py deleted file mode 100644 index 79e701e6..00000000 --- a/CTFd/plugins/challenges/__init__.py +++ /dev/null @@ -1,219 +0,0 @@ -from flask import Blueprint - -from CTFd.models import ( - ChallengeFiles, - Challenges, - Fails, - Flags, - Hints, - Solves, - Tags, - db, -) -from CTFd.plugins import register_plugin_assets_directory -from CTFd.plugins.flags import FlagException, get_flag_class -from CTFd.utils.uploads import delete_file -from CTFd.utils.user import get_ip - - -class BaseChallenge(object): - id = None - name = None - templates = {} - scripts = {} - challenge_model = Challenges - - @classmethod - def create(cls, request): - """ - This method is used to process the challenge creation request. - - :param request: - :return: - """ - data = request.form or request.get_json() - - challenge = cls.challenge_model(**data) - - db.session.add(challenge) - db.session.commit() - - return challenge - - @classmethod - def read(cls, challenge): - """ - This method is in used to access the data of a challenge in a format processable by the front end. - - :param challenge: - :return: Challenge object, data dictionary to be returned to the user - """ - data = { - "id": challenge.id, - "name": challenge.name, - "value": challenge.value, - "description": challenge.description, - "connection_info": challenge.connection_info, - "next_id": challenge.next_id, - "category": challenge.category, - "state": challenge.state, - "max_attempts": challenge.max_attempts, - "type": challenge.type, - "type_data": { - "id": cls.id, - "name": cls.name, - "templates": cls.templates, - "scripts": cls.scripts, - }, - } - return data - - @classmethod - def update(cls, challenge, request): - """ - This method is used to update the information associated with a challenge. This should be kept strictly to the - Challenges table and any child tables. - - :param challenge: - :param request: - :return: - """ - data = request.form or request.get_json() - for attr, value in data.items(): - setattr(challenge, attr, value) - - db.session.commit() - return challenge - - @classmethod - def delete(cls, challenge): - """ - This method is used to delete the resources used by a challenge. - - :param challenge: - :return: - """ - Fails.query.filter_by(challenge_id=challenge.id).delete() - Solves.query.filter_by(challenge_id=challenge.id).delete() - Flags.query.filter_by(challenge_id=challenge.id).delete() - files = ChallengeFiles.query.filter_by(challenge_id=challenge.id).all() - for f in files: - delete_file(f.id) - ChallengeFiles.query.filter_by(challenge_id=challenge.id).delete() - Tags.query.filter_by(challenge_id=challenge.id).delete() - Hints.query.filter_by(challenge_id=challenge.id).delete() - Challenges.query.filter_by(id=challenge.id).delete() - cls.challenge_model.query.filter_by(id=challenge.id).delete() - db.session.commit() - - @classmethod - def attempt(cls, challenge, request): - """ - This method is used to check whether a given input is right or wrong. It does not make any changes and should - return a boolean for correctness and a string to be shown to the user. It is also in charge of parsing the - user's input from the request itself. - - :param challenge: The Challenge object from the database - :param request: The request the user submitted - :return: (boolean, string) - """ - data = request.form or request.get_json() - submission = data["submission"].strip() - flags = Flags.query.filter_by(challenge_id=challenge.id).all() - 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" - - @classmethod - def solve(cls, user, team, challenge, request): - """ - This method is used to insert Solves into the database in order to mark a challenge as solved. - - :param team: The Team object from the database - :param chal: The Challenge object from the database - :param request: The request the user submitted - :return: - """ - data = request.form or request.get_json() - submission = data["submission"].strip() - solve = Solves( - user_id=user.id, - team_id=team.id if team else None, - challenge_id=challenge.id, - ip=get_ip(req=request), - provided=submission, - ) - db.session.add(solve) - db.session.commit() - - @classmethod - def fail(cls, user, team, challenge, request): - """ - This method is used to insert Fails into the database in order to mark an answer incorrect. - - :param team: The Team object from the database - :param chal: The Challenge object from the database - :param request: The request the user submitted - :return: - """ - data = request.form or request.get_json() - submission = data["submission"].strip() - wrong = Fails( - user_id=user.id, - team_id=team.id if team else None, - challenge_id=challenge.id, - ip=get_ip(request), - provided=submission, - ) - db.session.add(wrong) - db.session.commit() - - -class CTFdStandardChallenge(BaseChallenge): - id = "standard" # Unique identifier used to register challenges - name = "standard" # Name of a challenge type - templates = { # Templates used for each aspect of challenge editing & viewing - "create": "/plugins/challenges/assets/create.html", - "update": "/plugins/challenges/assets/update.html", - "view": "/plugins/challenges/assets/view.html", - } - scripts = { # Scripts that are loaded when a template is loaded - "create": "/plugins/challenges/assets/create.js", - "update": "/plugins/challenges/assets/update.js", - "view": "/plugins/challenges/assets/view.js", - } - # Route at which files are accessible. This must be registered using register_plugin_assets_directory() - route = "/plugins/challenges/assets/" - # Blueprint used to access the static_folder directory. - blueprint = Blueprint( - "standard", __name__, template_folder="templates", static_folder="assets" - ) - challenge_model = Challenges - - -def get_chal_class(class_id): - """ - Utility function used to get the corresponding class from a class ID. - - :param class_id: String representing the class ID - :return: Challenge class - """ - cls = CHALLENGE_CLASSES.get(class_id) - if cls is None: - raise KeyError - return cls - - -""" -Global dictionary used to hold all the Challenge Type classes used by CTFd. Insert into this dictionary to register -your Challenge Type. -""" -CHALLENGE_CLASSES = {"standard": CTFdStandardChallenge} - - -def load(app): - register_plugin_assets_directory(app, base_path="/plugins/challenges/assets/") diff --git a/CTFd/plugins/challenges/assets/create.html b/CTFd/plugins/challenges/assets/create.html deleted file mode 100644 index dd985c36..00000000 --- a/CTFd/plugins/challenges/assets/create.html +++ /dev/null @@ -1 +0,0 @@ -{% extends "admin/challenges/create.html" %} \ No newline at end of file diff --git a/CTFd/plugins/challenges/assets/create.js b/CTFd/plugins/challenges/assets/create.js deleted file mode 100644 index bcfe9c34..00000000 --- a/CTFd/plugins/challenges/assets/create.js +++ /dev/null @@ -1,4 +0,0 @@ -CTFd.plugin.run((_CTFd) => { - const $ = _CTFd.lib.$ - const md = _CTFd.lib.markdown() -}) diff --git a/CTFd/plugins/challenges/assets/update.html b/CTFd/plugins/challenges/assets/update.html deleted file mode 100644 index d097e1c1..00000000 --- a/CTFd/plugins/challenges/assets/update.html +++ /dev/null @@ -1 +0,0 @@ -{% extends "admin/challenges/update.html" %} \ No newline at end of file diff --git a/CTFd/plugins/challenges/assets/update.js b/CTFd/plugins/challenges/assets/update.js deleted file mode 100644 index e69de29b..00000000 diff --git a/CTFd/plugins/challenges/assets/view.html b/CTFd/plugins/challenges/assets/view.html deleted file mode 100644 index ab623afd..00000000 --- a/CTFd/plugins/challenges/assets/view.html +++ /dev/null @@ -1 +0,0 @@ -{% extends "challenge.html" %} \ No newline at end of file diff --git a/CTFd/plugins/challenges/assets/view.js b/CTFd/plugins/challenges/assets/view.js deleted file mode 100644 index b03a28e1..00000000 --- a/CTFd/plugins/challenges/assets/view.js +++ /dev/null @@ -1,37 +0,0 @@ -CTFd._internal.challenge.data = undefined; - -// TODO: Remove in CTFd v4.0 -CTFd._internal.challenge.renderer = null; - -CTFd._internal.challenge.preRender = function() {}; - -// TODO: Remove in CTFd v4.0 -CTFd._internal.challenge.render = null; - -CTFd._internal.challenge.postRender = function() {}; - -CTFd._internal.challenge.submit = function(preview) { - var challenge_id = parseInt(CTFd.lib.$("#challenge-id").val()); - var submission = CTFd.lib.$("#challenge-input").val(); - - var body = { - challenge_id: challenge_id, - submission: submission - }; - var params = {}; - if (preview) { - params["preview"] = true; - } - - return CTFd.api.post_challenge_attempt(params, body).then(function(response) { - if (response.status === 429) { - // User was ratelimited but process response - return response; - } - if (response.status === 403) { - // User is not logged in or CTF is paused. - return response; - } - return response; - }); -}; diff --git a/CTFd/plugins/dynamic_challenges/.gitignore b/CTFd/plugins/dynamic_challenges/.gitignore deleted file mode 100644 index 43ae0e2a..00000000 --- a/CTFd/plugins/dynamic_challenges/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -__pycache__/ -*.py[cod] diff --git a/CTFd/plugins/dynamic_challenges/README.md b/CTFd/plugins/dynamic_challenges/README.md deleted file mode 100644 index 6ffa28cb..00000000 --- a/CTFd/plugins/dynamic_challenges/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# Dynamic Value Challenges for CTFd - -It's becoming commonplace in CTF to see challenges whose point values decrease -after each solve. - -This CTFd plugin creates a dynamic challenge type which implements this -behavior. Each dynamic challenge starts with an initial point value and then -each solve will decrease the value of the challenge until a minimum point value. - -By reducing the value of the challenge on each solve, all users who have previously -solved the challenge will have lowered scores. Thus an easier and more solved -challenge will naturally have a lower point value than a harder and less solved -challenge. - -Within CTFd you are free to mix and match regular and dynamic challenges. - -The current implementation requires the challenge to keep track of three values: - -- Initial - The original point valuation -- Decay - The amount of solves before the challenge will be at the minimum -- Minimum - The lowest possible point valuation - -The value decay logic is implemented with the following math: - - - -![](https://raw.githubusercontent.com/CTFd/DynamicValueChallenge/master/function.png) - -or in pseudo code: - -``` -value = (((minimum - initial)/(decay**2)) * (solve_count**2)) + initial -value = math.ceil(value) -``` - -If the number generated is lower than the minimum, the minimum is chosen -instead. - -A parabolic function is chosen instead of an exponential or logarithmic decay function -so that higher valued challenges have a slower drop from their initial value. - -# Installation - -**REQUIRES: CTFd >= v1.2.0** - -1. Clone this repository to `CTFd/plugins`. It is important that the folder is - named `DynamicValueChallenge` so CTFd can serve the files in the `assets` - directory. diff --git a/CTFd/plugins/dynamic_challenges/__init__.py b/CTFd/plugins/dynamic_challenges/__init__.py deleted file mode 100644 index ab7aaca2..00000000 --- a/CTFd/plugins/dynamic_challenges/__init__.py +++ /dev/null @@ -1,156 +0,0 @@ -from __future__ import division # Use floating point for math calculations - -import math - -from flask import Blueprint - -from CTFd.models import Challenges, Solves, db -from CTFd.plugins import register_plugin_assets_directory -from CTFd.plugins.challenges import CHALLENGE_CLASSES, BaseChallenge -from CTFd.plugins.migrations import upgrade -from CTFd.utils.modes import get_model - - -class DynamicChallenge(Challenges): - __mapper_args__ = {"polymorphic_identity": "dynamic"} - 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) - - def __init__(self, *args, **kwargs): - super(DynamicChallenge, self).__init__(**kwargs) - self.value = kwargs["initial"] - - -class DynamicValueChallenge(BaseChallenge): - id = "dynamic" # Unique identifier used to register challenges - name = "dynamic" # Name of a challenge type - templates = { # Handlebars templates used for each aspect of challenge editing & viewing - "create": "/plugins/dynamic_challenges/assets/create.html", - "update": "/plugins/dynamic_challenges/assets/update.html", - "view": "/plugins/dynamic_challenges/assets/view.html", - } - scripts = { # Scripts that are loaded when a template is loaded - "create": "/plugins/dynamic_challenges/assets/create.js", - "update": "/plugins/dynamic_challenges/assets/update.js", - "view": "/plugins/dynamic_challenges/assets/view.js", - } - # Route at which files are accessible. This must be registered using register_plugin_assets_directory() - route = "/plugins/dynamic_challenges/assets/" - # Blueprint used to access the static_folder directory. - blueprint = Blueprint( - "dynamic_challenges", - __name__, - template_folder="templates", - static_folder="assets", - ) - challenge_model = DynamicChallenge - - @classmethod - def calculate_value(cls, challenge): - 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() - ) - - # 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 - - challenge.value = value - db.session.commit() - return challenge - - @classmethod - def read(cls, challenge): - """ - This method is in used to access the data of a challenge in a format processable by the front end. - - :param challenge: - :return: Challenge object, data dictionary to be returned to the user - """ - challenge = DynamicChallenge.query.filter_by(id=challenge.id).first() - data = { - "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, - }, - } - return data - - @classmethod - def update(cls, challenge, request): - """ - This method is used to update the information associated with a challenge. This should be kept strictly to the - Challenges table and any child tables. - - :param challenge: - :param request: - :return: - """ - data = request.form or request.get_json() - - for attr, value in data.items(): - # We need to set these to floats so that the next operations don't operate on strings - if attr in ("initial", "minimum", "decay"): - value = float(value) - setattr(challenge, attr, value) - - return DynamicValueChallenge.calculate_value(challenge) - - @classmethod - def solve(cls, user, team, challenge, request): - super().solve(user, team, challenge, request) - - DynamicValueChallenge.calculate_value(challenge) - - -def load(app): - upgrade(plugin_name="dynamic_challenges") - CHALLENGE_CLASSES["dynamic"] = DynamicValueChallenge - register_plugin_assets_directory( - app, base_path="/plugins/dynamic_challenges/assets/" - ) diff --git a/CTFd/plugins/dynamic_challenges/assets/create.html b/CTFd/plugins/dynamic_challenges/assets/create.html deleted file mode 100644 index d8f3e2a1..00000000 --- a/CTFd/plugins/dynamic_challenges/assets/create.html +++ /dev/null @@ -1,43 +0,0 @@ -{% extends "admin/challenges/create.html" %} - -{% block header %} - -{% endblock %} - - -{% block value %} -
- - - -
- -
- - -
- -
- - -
-{% endblock %} - -{% block type %} - -{% endblock %} \ No newline at end of file diff --git a/CTFd/plugins/dynamic_challenges/assets/create.js b/CTFd/plugins/dynamic_challenges/assets/create.js deleted file mode 100644 index bcfe9c34..00000000 --- a/CTFd/plugins/dynamic_challenges/assets/create.js +++ /dev/null @@ -1,4 +0,0 @@ -CTFd.plugin.run((_CTFd) => { - const $ = _CTFd.lib.$ - const md = _CTFd.lib.markdown() -}) diff --git a/CTFd/plugins/dynamic_challenges/assets/update.html b/CTFd/plugins/dynamic_challenges/assets/update.html deleted file mode 100644 index d432fe34..00000000 --- a/CTFd/plugins/dynamic_challenges/assets/update.html +++ /dev/null @@ -1,39 +0,0 @@ -{% extends "admin/challenges/update.html" %} - -{% block value %} -
- - -
- -
- - -
- -
- - -
- -
- - -
-{% endblock %} \ No newline at end of file diff --git a/CTFd/plugins/dynamic_challenges/assets/update.js b/CTFd/plugins/dynamic_challenges/assets/update.js deleted file mode 100644 index e69de29b..00000000 diff --git a/CTFd/plugins/dynamic_challenges/assets/view.html b/CTFd/plugins/dynamic_challenges/assets/view.html deleted file mode 100644 index ab623afd..00000000 --- a/CTFd/plugins/dynamic_challenges/assets/view.html +++ /dev/null @@ -1 +0,0 @@ -{% extends "challenge.html" %} \ No newline at end of file diff --git a/CTFd/plugins/dynamic_challenges/assets/view.js b/CTFd/plugins/dynamic_challenges/assets/view.js deleted file mode 100644 index b03a28e1..00000000 --- a/CTFd/plugins/dynamic_challenges/assets/view.js +++ /dev/null @@ -1,37 +0,0 @@ -CTFd._internal.challenge.data = undefined; - -// TODO: Remove in CTFd v4.0 -CTFd._internal.challenge.renderer = null; - -CTFd._internal.challenge.preRender = function() {}; - -// TODO: Remove in CTFd v4.0 -CTFd._internal.challenge.render = null; - -CTFd._internal.challenge.postRender = function() {}; - -CTFd._internal.challenge.submit = function(preview) { - var challenge_id = parseInt(CTFd.lib.$("#challenge-id").val()); - var submission = CTFd.lib.$("#challenge-input").val(); - - var body = { - challenge_id: challenge_id, - submission: submission - }; - var params = {}; - if (preview) { - params["preview"] = true; - } - - return CTFd.api.post_challenge_attempt(params, body).then(function(response) { - if (response.status === 429) { - // User was ratelimited but process response - return response; - } - if (response.status === 403) { - // User is not logged in or CTF is paused. - return response; - } - return response; - }); -}; diff --git a/CTFd/plugins/dynamic_challenges/function.png b/CTFd/plugins/dynamic_challenges/function.png deleted file mode 100644 index b35e45b0..00000000 Binary files a/CTFd/plugins/dynamic_challenges/function.png and /dev/null differ diff --git a/CTFd/plugins/dynamic_challenges/migrations/b37fb68807ea_add_cascading_delete_to_dynamic_.py b/CTFd/plugins/dynamic_challenges/migrations/b37fb68807ea_add_cascading_delete_to_dynamic_.py deleted file mode 100644 index b28d5708..00000000 --- a/CTFd/plugins/dynamic_challenges/migrations/b37fb68807ea_add_cascading_delete_to_dynamic_.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Add cascading delete to dynamic challenges - -Revision ID: b37fb68807ea -Revises: -Create Date: 2020-05-06 12:21:39.373983 - -""" -# revision identifiers, used by Alembic. -revision = "b37fb68807ea" -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(op=None): - bind = op.get_bind() - url = str(bind.engine.url) - if url.startswith("mysql"): - op.drop_constraint( - "dynamic_challenge_ibfk_1", "dynamic_challenge", type_="foreignkey" - ) - elif url.startswith("postgres"): - op.drop_constraint( - "dynamic_challenge_id_fkey", "dynamic_challenge", type_="foreignkey" - ) - - op.create_foreign_key( - None, "dynamic_challenge", "challenges", ["id"], ["id"], ondelete="CASCADE" - ) - # ### end Alembic commands ### - - -def downgrade(op=None): - bind = op.get_bind() - url = str(bind.engine.url) - if url.startswith("mysql"): - op.drop_constraint( - "dynamic_challenge_ibfk_1", "dynamic_challenge", type_="foreignkey" - ) - elif url.startswith("postgres"): - op.drop_constraint( - "dynamic_challenge_id_fkey", "dynamic_challenge", type_="foreignkey" - ) - - op.create_foreign_key(None, "dynamic_challenge", "challenges", ["id"], ["id"]) diff --git a/CTFd/plugins/flags/__init__.py b/CTFd/plugins/flags/__init__.py deleted file mode 100644 index a468d111..00000000 --- a/CTFd/plugins/flags/__init__.py +++ /dev/null @@ -1,83 +0,0 @@ -import re - -from CTFd.plugins import register_plugin_assets_directory - - -class FlagException(Exception): - def __init__(self, message): - self.message = message - - def __str__(self): - return self.message - - -class BaseFlag(object): - name = None - templates = {} - - @staticmethod - def compare(self, saved, provided): - return True - - -class CTFdStaticFlag(BaseFlag): - name = "static" - templates = { # Nunjucks templates used for key editing & viewing - "create": "/plugins/flags/assets/static/create.html", - "update": "/plugins/flags/assets/static/edit.html", - } - - @staticmethod - def compare(chal_key_obj, provided): - saved = chal_key_obj.content - data = chal_key_obj.data - - if len(saved) != len(provided): - return False - result = 0 - - if data == "case_insensitive": - for x, y in zip(saved.lower(), provided.lower()): - result |= ord(x) ^ ord(y) - else: - for x, y in zip(saved, provided): - result |= ord(x) ^ ord(y) - return result == 0 - - -class CTFdRegexFlag(BaseFlag): - name = "regex" - templates = { # Nunjucks templates used for key editing & viewing - "create": "/plugins/flags/assets/regex/create.html", - "update": "/plugins/flags/assets/regex/edit.html", - } - - @staticmethod - def compare(chal_key_obj, provided): - saved = chal_key_obj.content - data = chal_key_obj.data - - try: - if data == "case_insensitive": - res = re.match(saved, provided, re.IGNORECASE) - else: - res = re.match(saved, provided) - # TODO: this needs plugin improvements. See #1425. - except re.error as e: - raise FlagException("Regex parse error occured") from e - - return res and res.group() == provided - - -FLAG_CLASSES = {"static": CTFdStaticFlag, "regex": CTFdRegexFlag} - - -def get_flag_class(class_id): - cls = FLAG_CLASSES.get(class_id) - if cls is None: - raise KeyError - return cls - - -def load(app): - register_plugin_assets_directory(app, base_path="/plugins/flags/assets/") diff --git a/CTFd/plugins/flags/assets/regex/create.html b/CTFd/plugins/flags/assets/regex/create.html deleted file mode 100644 index cdad7b8d..00000000 --- a/CTFd/plugins/flags/assets/regex/create.html +++ /dev/null @@ -1,37 +0,0 @@ - -
- -
-
- -
-
- - - - - - - - - - - - - - - - - - - - -
DescriptionFlag
Match any content inside of flag{}:flag{.*}
Match numeric flags:flag{(\d+)}
Accept flags with or without the flag format prefix:(flag{)?this_is_a_flag(})?
-
- \ No newline at end of file diff --git a/CTFd/plugins/flags/assets/regex/edit.html b/CTFd/plugins/flags/assets/regex/edit.html deleted file mode 100644 index e07e5de6..00000000 --- a/CTFd/plugins/flags/assets/regex/edit.html +++ /dev/null @@ -1,19 +0,0 @@ - -
- -
-
- -
- - -
-
- -
diff --git a/CTFd/plugins/flags/assets/static/create.html b/CTFd/plugins/flags/assets/static/create.html deleted file mode 100644 index 4f9fa8b0..00000000 --- a/CTFd/plugins/flags/assets/static/create.html +++ /dev/null @@ -1,14 +0,0 @@ - -
- -
-
- -
- \ No newline at end of file diff --git a/CTFd/plugins/flags/assets/static/edit.html b/CTFd/plugins/flags/assets/static/edit.html deleted file mode 100644 index 1ef3e114..00000000 --- a/CTFd/plugins/flags/assets/static/edit.html +++ /dev/null @@ -1,19 +0,0 @@ - -
- -
-
- -
- - -
-
- -
diff --git a/CTFd/plugins/migrations.py b/CTFd/plugins/migrations.py deleted file mode 100644 index 36bcae47..00000000 --- a/CTFd/plugins/migrations.py +++ /dev/null @@ -1,109 +0,0 @@ -import inspect # noqa: I001 -import os - -from alembic.config import Config -from alembic.migration import MigrationContext -from alembic.operations import Operations -from alembic.script import ScriptDirectory -from flask import current_app -from sqlalchemy import create_engine -from sqlalchemy import inspect as SQLAInspect -from sqlalchemy import pool - -from CTFd.utils import _get_config, set_config - - -def get_all_tables(op): - """ - Function to list all the tables in the database from a migration - """ - inspector = SQLAInspect(op.get_bind()) - tables = inspector.get_table_names() - return tables - - -def get_columns_for_table(op, table_name, names_only=False): - """ - Function to list the columns in a table from a migration - """ - inspector = SQLAInspect(op.get_bind()) - columns = inspector.get_columns(table_name) - if names_only is True: - columns = [c["name"] for c in columns] - return columns - - -def current(plugin_name=None): - if plugin_name is None: - # Get the directory name of the plugin if unspecified - # Doing it this way doesn't waste the rest of the inspect.stack call - frame = inspect.currentframe() - caller_info = inspect.getframeinfo(frame.f_back) - caller_path = caller_info[0] - plugin_name = os.path.basename(os.path.dirname(caller_path)) - - # Specifically bypass the cached config so that we always get the database value - version = _get_config.__wrapped__(plugin_name + "_alembic_version") - if version == KeyError: - version = None - return version - - -def upgrade(plugin_name=None, revision=None, lower="current"): - database_url = current_app.config.get("SQLALCHEMY_DATABASE_URI") - if database_url.startswith("sqlite"): - current_app.db.create_all() - return - - if plugin_name is None: - # Get the directory name of the plugin if unspecified - # Doing it this way doesn't waste the rest of the inspect.stack call - frame = inspect.currentframe() - caller_info = inspect.getframeinfo(frame.f_back) - caller_path = caller_info[0] - plugin_name = os.path.basename(os.path.dirname(caller_path)) - - # Check if the plugin has migraitons - migrations_path = os.path.join(current_app.plugins_dir, plugin_name, "migrations") - if os.path.isdir(migrations_path) is False: - return - - engine = create_engine(database_url, poolclass=pool.NullPool) - conn = engine.connect() - context = MigrationContext.configure(conn) - op = Operations(context) - - # Find the list of migrations to run - config = Config() - config.set_main_option("script_location", migrations_path) - config.set_main_option("version_locations", migrations_path) - script = ScriptDirectory.from_config(config) - - # Choose base revision for plugin upgrade - # "current" points to the current plugin version stored in config - # None represents the absolute base layer (e.g. first installation) - if lower == "current": - lower = current(plugin_name) - - # Do we upgrade to head or to a specific revision - if revision is None: - upper = script.get_current_head() - else: - upper = revision - - # Apply from lower to upper - revs = list(script.iterate_revisions(lower=lower, upper=upper)) - revs.reverse() - - try: - for r in revs: - with context.begin_transaction(): - r.module.upgrade(op=op) - # Set revision that succeeded so we don't need - # to start from the beginning on failure - set_config(plugin_name + "_alembic_version", r.revision) - finally: - conn.close() - - # Set the new latest revision - set_config(plugin_name + "_alembic_version", upper) diff --git a/CTFd/schemas/__init__.py b/CTFd/schemas/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/CTFd/schemas/awards.py b/CTFd/schemas/awards.py deleted file mode 100644 index 728e902c..00000000 --- a/CTFd/schemas/awards.py +++ /dev/null @@ -1,48 +0,0 @@ -from CTFd.models import Awards, ma -from CTFd.utils import string_types - - -class AwardSchema(ma.ModelSchema): - class Meta: - model = Awards - include_fk = True - dump_only = ("id", "date") - - views = { - "admin": [ - "category", - "user_id", - "name", - "description", - "value", - "team_id", - "user", - "team", - "date", - "requirements", - "id", - "icon", - ], - "user": [ - "category", - "user_id", - "name", - "description", - "value", - "team_id", - "user", - "team", - "date", - "id", - "icon", - ], - } - - def __init__(self, view=None, *args, **kwargs): - if view: - if isinstance(view, string_types): - kwargs["only"] = self.views[view] - elif isinstance(view, list): - kwargs["only"] = view - - super(AwardSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/challenges.py b/CTFd/schemas/challenges.py deleted file mode 100644 index 3cd9615a..00000000 --- a/CTFd/schemas/challenges.py +++ /dev/null @@ -1,72 +0,0 @@ -from marshmallow import validate -from marshmallow.exceptions import ValidationError -from marshmallow_sqlalchemy import field_for - -from CTFd.models import Challenges, ma - - -class ChallengeRequirementsValidator(validate.Validator): - default_message = "Error parsing challenge requirements" - - def __init__(self, error=None): - self.error = error or self.default_message - - def __call__(self, value): - if isinstance(value, dict) is False: - raise ValidationError(self.default_message) - - prereqs = value.get("prerequisites", []) - if all(prereqs) is False: - raise ValidationError( - "Challenge requirements cannot have a null prerequisite" - ) - - return value - - -class ChallengeSchema(ma.ModelSchema): - class Meta: - model = Challenges - include_fk = True - dump_only = ("id",) - - name = field_for( - Challenges, - "name", - validate=[ - validate.Length( - min=0, - max=80, - error="Challenge could not be saved. Challenge name too long", - ) - ], - ) - - category = field_for( - Challenges, - "category", - validate=[ - validate.Length( - min=0, - max=80, - error="Challenge could not be saved. Challenge category too long", - ) - ], - ) - - description = field_for( - Challenges, - "description", - allow_none=True, - validate=[ - validate.Length( - min=0, - max=65535, - error="Challenge could not be saved. Challenge description too long", - ) - ], - ) - - requirements = field_for( - Challenges, "requirements", validate=[ChallengeRequirementsValidator()], - ) diff --git a/CTFd/schemas/comments.py b/CTFd/schemas/comments.py deleted file mode 100644 index d5ef43bf..00000000 --- a/CTFd/schemas/comments.py +++ /dev/null @@ -1,14 +0,0 @@ -from marshmallow import fields - -from CTFd.models import Comments, ma -from CTFd.schemas.users import UserSchema - - -class CommentSchema(ma.ModelSchema): - class Meta: - model = Comments - include_fk = True - dump_only = ("id", "date", "html", "author", "author_id", "type") - - author = fields.Nested(UserSchema(only=("name",))) - html = fields.String() diff --git a/CTFd/schemas/config.py b/CTFd/schemas/config.py deleted file mode 100644 index 266c18f0..00000000 --- a/CTFd/schemas/config.py +++ /dev/null @@ -1,43 +0,0 @@ -from marshmallow import fields -from marshmallow.exceptions import ValidationError -from marshmallow_sqlalchemy import field_for - -from CTFd.models import Configs, ma -from CTFd.utils import string_types - - -class ConfigValueField(fields.Field): - """ - Custom value field for Configs so that we can perform validation of values - """ - - def _deserialize(self, value, attr, data, **kwargs): - if isinstance(value, str): - # 65535 bytes is the size of a TEXT column in MySQL - # You may be able to exceed this in other databases - # but MySQL is our database of record - if len(value) > 65535: - raise ValidationError(f'{data["key"]} config is too long') - return value - else: - return value - - -class ConfigSchema(ma.ModelSchema): - class Meta: - model = Configs - include_fk = True - dump_only = ("id",) - - views = {"admin": ["id", "key", "value"]} - key = field_for(Configs, "key", required=True) - value = ConfigValueField(allow_none=True, required=True) - - def __init__(self, view=None, *args, **kwargs): - if view: - if isinstance(view, string_types): - kwargs["only"] = self.views[view] - elif isinstance(view, list): - kwargs["only"] = view - - super(ConfigSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/fields.py b/CTFd/schemas/fields.py deleted file mode 100644 index 0199b9d7..00000000 --- a/CTFd/schemas/fields.py +++ /dev/null @@ -1,38 +0,0 @@ -from marshmallow import fields - -from CTFd.models import Fields, TeamFieldEntries, UserFieldEntries, db, ma - - -class FieldSchema(ma.ModelSchema): - class Meta: - model = Fields - include_fk = True - dump_only = ("id",) - - -class UserFieldEntriesSchema(ma.ModelSchema): - class Meta: - model = UserFieldEntries - sqla_session = db.session - include_fk = True - load_only = ("id",) - exclude = ("field", "user", "user_id") - dump_only = ("user_id", "name", "description", "type") - - name = fields.Nested(FieldSchema, only=("name"), attribute="field") - description = fields.Nested(FieldSchema, only=("description"), attribute="field") - type = fields.Nested(FieldSchema, only=("field_type"), attribute="field") - - -class TeamFieldEntriesSchema(ma.ModelSchema): - class Meta: - model = TeamFieldEntries - sqla_session = db.session - include_fk = True - load_only = ("id",) - exclude = ("field", "team", "team_id") - dump_only = ("team_id", "name", "description", "type") - - name = fields.Nested(FieldSchema, only=("name"), attribute="field") - description = fields.Nested(FieldSchema, only=("description"), attribute="field") - type = fields.Nested(FieldSchema, only=("field_type"), attribute="field") diff --git a/CTFd/schemas/files.py b/CTFd/schemas/files.py deleted file mode 100644 index c401aa6a..00000000 --- a/CTFd/schemas/files.py +++ /dev/null @@ -1,18 +0,0 @@ -from CTFd.models import Files, ma -from CTFd.utils import string_types - - -class FileSchema(ma.ModelSchema): - class Meta: - model = Files - include_fk = True - dump_only = ("id", "type", "location") - - def __init__(self, view=None, *args, **kwargs): - if view: - if isinstance(view, string_types): - kwargs["only"] = self.views[view] - elif isinstance(view, list): - kwargs["only"] = view - - super(FileSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/flags.py b/CTFd/schemas/flags.py deleted file mode 100644 index 3af60bc2..00000000 --- a/CTFd/schemas/flags.py +++ /dev/null @@ -1,18 +0,0 @@ -from CTFd.models import Flags, ma -from CTFd.utils import string_types - - -class FlagSchema(ma.ModelSchema): - class Meta: - model = Flags - include_fk = True - dump_only = ("id",) - - def __init__(self, view=None, *args, **kwargs): - if view: - if isinstance(view, string_types): - kwargs["only"] = self.views[view] - elif isinstance(view, list): - kwargs["only"] = view - - super(FlagSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/hints.py b/CTFd/schemas/hints.py deleted file mode 100644 index da06d00b..00000000 --- a/CTFd/schemas/hints.py +++ /dev/null @@ -1,41 +0,0 @@ -from CTFd.models import Hints, ma -from CTFd.utils import string_types - - -class HintSchema(ma.ModelSchema): - class Meta: - model = Hints - include_fk = True - dump_only = ("id", "type", "html") - - views = { - "locked": ["id", "type", "challenge", "challenge_id", "cost"], - "unlocked": [ - "id", - "type", - "challenge", - "challenge_id", - "content", - "html", - "cost", - ], - "admin": [ - "id", - "type", - "challenge", - "challenge_id", - "content", - "html", - "cost", - "requirements", - ], - } - - def __init__(self, view=None, *args, **kwargs): - if view: - if isinstance(view, string_types): - kwargs["only"] = self.views[view] - elif isinstance(view, list): - kwargs["only"] = view - - super(HintSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/notifications.py b/CTFd/schemas/notifications.py deleted file mode 100644 index 8c3adf22..00000000 --- a/CTFd/schemas/notifications.py +++ /dev/null @@ -1,23 +0,0 @@ -from marshmallow import fields - -from CTFd.models import Notifications, ma -from CTFd.utils import string_types - - -class NotificationSchema(ma.ModelSchema): - class Meta: - model = Notifications - include_fk = True - dump_only = ("id", "date", "html") - - # Used to force the schema to include the html property from the model - html = fields.Str() - - def __init__(self, view=None, *args, **kwargs): - if view: - if isinstance(view, string_types): - kwargs["only"] = self.views[view] - elif isinstance(view, list): - kwargs["only"] = view - - super(NotificationSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/pages.py b/CTFd/schemas/pages.py deleted file mode 100644 index de7b9d99..00000000 --- a/CTFd/schemas/pages.py +++ /dev/null @@ -1,63 +0,0 @@ -from marshmallow import pre_load, validate -from marshmallow_sqlalchemy import field_for - -from CTFd.models import Pages, ma -from CTFd.utils import string_types - - -class PageSchema(ma.ModelSchema): - class Meta: - model = Pages - include_fk = True - dump_only = ("id",) - - title = field_for( - Pages, - "title", - validate=[ - validate.Length( - min=0, max=80, error="Page could not be saved. Your title is too long.", - ) - ], - ) - - route = field_for( - Pages, - "route", - allow_none=True, - validate=[ - validate.Length( - min=0, - max=128, - error="Page could not be saved. Your route is too long.", - ) - ], - ) - - content = field_for( - Pages, - "content", - allow_none=True, - validate=[ - validate.Length( - min=0, - max=65535, - error="Page could not be saved. Your content is too long.", - ) - ], - ) - - @pre_load - def validate_route(self, data): - route = data.get("route") - if route and route.startswith("/"): - data["route"] = route.strip("/") - - def __init__(self, view=None, *args, **kwargs): - if view: - if isinstance(view, string_types): - kwargs["only"] = self.views[view] - elif isinstance(view, list): - kwargs["only"] = view - - super(PageSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/submissions.py b/CTFd/schemas/submissions.py deleted file mode 100644 index 8b439f04..00000000 --- a/CTFd/schemas/submissions.py +++ /dev/null @@ -1,42 +0,0 @@ -from marshmallow import fields - -from CTFd.models import Submissions, ma -from CTFd.schemas.challenges import ChallengeSchema -from CTFd.schemas.teams import TeamSchema -from CTFd.schemas.users import UserSchema -from CTFd.utils import string_types - - -class SubmissionSchema(ma.ModelSchema): - challenge = fields.Nested(ChallengeSchema, only=["id", "name", "category", "value"]) - user = fields.Nested(UserSchema, only=["id", "name"]) - team = fields.Nested(TeamSchema, only=["id", "name"]) - - class Meta: - model = Submissions - include_fk = True - dump_only = ("id",) - - views = { - "admin": [ - "provided", - "ip", - "challenge_id", - "challenge", - "user", - "team", - "date", - "type", - "id", - ], - "user": ["challenge_id", "challenge", "user", "team", "date", "type", "id"], - } - - def __init__(self, view=None, *args, **kwargs): - if view: - if isinstance(view, string_types): - kwargs["only"] = self.views[view] - elif isinstance(view, list): - kwargs["only"] = view - - super(SubmissionSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/tags.py b/CTFd/schemas/tags.py deleted file mode 100644 index ac4039e0..00000000 --- a/CTFd/schemas/tags.py +++ /dev/null @@ -1,20 +0,0 @@ -from CTFd.models import Tags, ma -from CTFd.utils import string_types - - -class TagSchema(ma.ModelSchema): - class Meta: - model = Tags - include_fk = True - dump_only = ("id",) - - views = {"admin": ["id", "challenge", "value"], "user": ["value"]} - - def __init__(self, view=None, *args, **kwargs): - if view: - if isinstance(view, string_types): - kwargs["only"] = self.views[view] - elif isinstance(view, list): - kwargs["only"] = view - - super(TagSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/teams.py b/CTFd/schemas/teams.py deleted file mode 100644 index 208369c7..00000000 --- a/CTFd/schemas/teams.py +++ /dev/null @@ -1,381 +0,0 @@ -from marshmallow import ValidationError, post_dump, pre_load, validate -from marshmallow.fields import Nested -from marshmallow_sqlalchemy import field_for -from sqlalchemy.orm import load_only - -from CTFd.models import TeamFieldEntries, TeamFields, Teams, Users, ma -from CTFd.schemas.fields import TeamFieldEntriesSchema -from CTFd.utils import get_config, string_types -from CTFd.utils.crypto import verify_password -from CTFd.utils.user import get_current_team, get_current_user, is_admin -from CTFd.utils.validators import validate_country_code - - -class TeamSchema(ma.ModelSchema): - class Meta: - model = Teams - include_fk = True - dump_only = ("id", "oauth_id", "created", "members") - load_only = ("password",) - - name = field_for( - Teams, - "name", - required=True, - allow_none=False, - validate=[ - validate.Length(min=1, max=128, error="Team names must not be empty") - ], - ) - email = field_for( - Teams, - "email", - allow_none=False, - validate=validate.Email("Emails must be a properly formatted email address"), - ) - password = field_for(Teams, "password", required=True, allow_none=False) - website = field_for( - Teams, - "website", - validate=[ - # This is a dirty hack to let website accept empty strings so you can remove your website - lambda website: validate.URL( - error="Websites must be a proper URL starting with http or https", - schemes={"http", "https"}, - )(website) - if website - else True - ], - ) - country = field_for(Teams, "country", validate=[validate_country_code]) - fields = Nested( - TeamFieldEntriesSchema, partial=True, many=True, attribute="field_entries" - ) - - @pre_load - def validate_name(self, data): - name = data.get("name") - if name is None: - return - name = name.strip() - - existing_team = Teams.query.filter_by(name=name).first() - current_team = get_current_team() - # Admins should be able to patch anyone but they cannot cause a collision. - if is_admin(): - team_id = int(data.get("id", 0)) - if team_id: - if existing_team and existing_team.id != team_id: - raise ValidationError( - "Team name has already been taken", field_names=["name"] - ) - else: - # If there's no Team ID it means that the admin is creating a team with no ID. - if existing_team: - if current_team: - if current_team.id != existing_team.id: - raise ValidationError( - "Team name has already been taken", field_names=["name"] - ) - else: - raise ValidationError( - "Team name has already been taken", field_names=["name"] - ) - else: - # We need to allow teams to edit themselves and allow the "conflict" - if data["name"] == current_team.name: - return data - else: - name_changes = get_config("name_changes", default=True) - if bool(name_changes) is False: - raise ValidationError( - "Name changes are disabled", field_names=["name"] - ) - - if existing_team: - raise ValidationError( - "Team name has already been taken", field_names=["name"] - ) - - @pre_load - def validate_email(self, data): - email = data.get("email") - if email is None: - return - - existing_team = Teams.query.filter_by(email=email).first() - if is_admin(): - team_id = data.get("id") - if team_id: - if existing_team and existing_team.id != team_id: - raise ValidationError( - "Email address has already been used", field_names=["email"] - ) - else: - if existing_team: - raise ValidationError( - "Email address has already been used", field_names=["email"] - ) - else: - current_team = get_current_team() - if email == current_team.email: - return data - else: - if existing_team: - raise ValidationError( - "Email address has already been used", field_names=["email"] - ) - - @pre_load - def validate_password_confirmation(self, data): - password = data.get("password") - confirm = data.get("confirm") - - if is_admin(): - pass - else: - current_team = get_current_team() - current_user = get_current_user() - - if current_team.captain_id != current_user.id: - raise ValidationError( - "Only the captain can change the team password", - field_names=["captain_id"], - ) - - if password and (bool(confirm) is False): - raise ValidationError( - "Please confirm your current password", field_names=["confirm"] - ) - - if password and confirm: - test_team = verify_password( - plaintext=confirm, ciphertext=current_team.password - ) - test_captain = verify_password( - plaintext=confirm, ciphertext=current_user.password - ) - if test_team is True or test_captain is True: - return data - else: - raise ValidationError( - "Your previous password is incorrect", field_names=["confirm"] - ) - else: - data.pop("password", None) - data.pop("confirm", None) - - @pre_load - def validate_captain_id(self, data): - captain_id = data.get("captain_id") - if captain_id is None: - return - - if is_admin(): - team_id = data.get("id") - if team_id: - target_team = Teams.query.filter_by(id=team_id).first() - else: - target_team = get_current_team() - captain = Users.query.filter_by(id=captain_id).first() - if captain in target_team.members: - return - else: - raise ValidationError("Invalid Captain ID", field_names=["captain_id"]) - else: - current_team = get_current_team() - current_user = get_current_user() - if current_team.captain_id == current_user.id: - captain = Users.query.filter_by(id=captain_id).first() - if captain in current_team.members: - return - else: - raise ValidationError( - "Only team members can be promoted to captain", - field_names=["captain_id"], - ) - else: - raise ValidationError( - "Only the captain can change team captain", - field_names=["captain_id"], - ) - - @pre_load - def validate_fields(self, data): - """ - This validator is used to only allow users to update the field entry for their user. - It's not possible to exclude it because without the PK Marshmallow cannot load the right instance - """ - fields = data.get("fields") - if fields is None: - return - - current_team = get_current_team() - - if is_admin(): - team_id = data.get("id") - if team_id: - target_team = Teams.query.filter_by(id=data["id"]).first() - else: - target_team = current_team - - # We are editting an existing - if self.view == "admin" and self.instance: - target_team = self.instance - provided_ids = [] - for f in fields: - f.pop("id", None) - field_id = f.get("field_id") - - # # Check that we have an existing field for this. May be unnecessary b/c the foriegn key should enforce - field = TeamFields.query.filter_by(id=field_id).first_or_404() - - # Get the existing field entry if one exists - entry = TeamFieldEntries.query.filter_by( - field_id=field.id, team_id=target_team.id - ).first() - if entry: - f["id"] = entry.id - provided_ids.append(entry.id) - - # Extremely dirty hack to prevent deleting previously provided data. - # This needs a better soln. - entries = ( - TeamFieldEntries.query.options(load_only("id")) - .filter_by(team_id=target_team.id) - .all() - ) - for entry in entries: - if entry.id not in provided_ids: - fields.append({"id": entry.id}) - else: - provided_ids = [] - for f in fields: - # Remove any existing set - f.pop("id", None) - field_id = f.get("field_id") - value = f.get("value") - - # # Check that we have an existing field for this. May be unnecessary b/c the foriegn key should enforce - field = TeamFields.query.filter_by(id=field_id).first_or_404() - - # Get the existing field entry if one exists - entry = TeamFieldEntries.query.filter_by( - field_id=field.id, team_id=current_team.id - ).first() - - if field.required is True: - if isinstance(value, str): - if value.strip() == "": - raise ValidationError( - f"Field '{field.name}' is required", - field_names=["fields"], - ) - - if field.editable is False and entry is not None: - raise ValidationError( - f"Field '{field.name}' cannot be editted", - field_names=["fields"], - ) - - if entry: - f["id"] = entry.id - provided_ids.append(entry.id) - - # Extremely dirty hack to prevent deleting previously provided data. - # This needs a better soln. - entries = ( - TeamFieldEntries.query.options(load_only("id")) - .filter_by(team_id=current_team.id) - .all() - ) - for entry in entries: - if entry.id not in provided_ids: - fields.append({"id": entry.id}) - - @post_dump - def process_fields(self, data): - """ - Handle permissions levels for fields. - This is post_dump to manipulate JSON instead of the raw db object - - Admins can see all fields. - Users (self) can see their edittable and public fields - Public (user) can only see public fields - """ - # Gather all possible fields - removed_field_ids = [] - fields = TeamFields.query.all() - - # Select fields for removal based on current view and properties of the field - for field in fields: - if self.view == "user": - if field.public is False: - removed_field_ids.append(field.id) - elif self.view == "self": - if field.editable is False and field.public is False: - removed_field_ids.append(field.id) - - # Rebuild fuilds - fields = data.get("fields") - if fields: - data["fields"] = [ - field for field in fields if field["field_id"] not in removed_field_ids - ] - - views = { - "user": [ - "website", - "name", - "country", - "affiliation", - "bracket", - "members", - "id", - "oauth_id", - "captain_id", - "fields", - ], - "self": [ - "website", - "name", - "email", - "country", - "affiliation", - "bracket", - "members", - "id", - "oauth_id", - "password", - "captain_id", - "fields", - ], - "admin": [ - "website", - "name", - "created", - "country", - "banned", - "email", - "affiliation", - "secret", - "bracket", - "members", - "hidden", - "id", - "oauth_id", - "password", - "captain_id", - "fields", - ], - } - - def __init__(self, view=None, *args, **kwargs): - if view: - if isinstance(view, string_types): - kwargs["only"] = self.views[view] - elif isinstance(view, list): - kwargs["only"] = view - self.view = view - - super(TeamSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/tokens.py b/CTFd/schemas/tokens.py deleted file mode 100644 index d6ae4d36..00000000 --- a/CTFd/schemas/tokens.py +++ /dev/null @@ -1,23 +0,0 @@ -from CTFd.models import Tokens, ma -from CTFd.utils import string_types - - -class TokenSchema(ma.ModelSchema): - class Meta: - model = Tokens - include_fk = True - dump_only = ("id", "expiration", "type") - - views = { - "admin": ["id", "type", "user_id", "created", "expiration", "value"], - "user": ["id", "type", "created", "expiration"], - } - - def __init__(self, view=None, *args, **kwargs): - if view: - if isinstance(view, string_types): - kwargs["only"] = self.views[view] - elif isinstance(view, list): - kwargs["only"] = view - - super(TokenSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/topics.py b/CTFd/schemas/topics.py deleted file mode 100644 index c71e8d90..00000000 --- a/CTFd/schemas/topics.py +++ /dev/null @@ -1,38 +0,0 @@ -from CTFd.models import ChallengeTopics, Topics, ma -from CTFd.utils import string_types - - -class TopicSchema(ma.ModelSchema): - class Meta: - model = Topics - include_fk = True - dump_only = ("id",) - - views = {"admin": ["id", "value"]} - - def __init__(self, view=None, *args, **kwargs): - if view: - if isinstance(view, string_types): - kwargs["only"] = self.views[view] - elif isinstance(view, list): - kwargs["only"] = view - - super(TopicSchema, self).__init__(*args, **kwargs) - - -class ChallengeTopicSchema(ma.ModelSchema): - class Meta: - model = ChallengeTopics - include_fk = True - dump_only = ("id",) - - views = {"admin": ["id", "challenge_id", "topic_id"]} - - def __init__(self, view=None, *args, **kwargs): - if view: - if isinstance(view, string_types): - kwargs["only"] = self.views[view] - elif isinstance(view, list): - kwargs["only"] = view - - super(ChallengeTopicSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/unlocks.py b/CTFd/schemas/unlocks.py deleted file mode 100644 index 964b453d..00000000 --- a/CTFd/schemas/unlocks.py +++ /dev/null @@ -1,23 +0,0 @@ -from CTFd.models import Unlocks, ma -from CTFd.utils import string_types - - -class UnlockSchema(ma.ModelSchema): - class Meta: - model = Unlocks - include_fk = True - dump_only = ("id", "date") - - views = { - "admin": ["user_id", "target", "team_id", "date", "type", "id"], - "user": ["target", "date", "type", "id"], - } - - def __init__(self, view=None, *args, **kwargs): - if view: - if isinstance(view, string_types): - kwargs["only"] = self.views[view] - elif isinstance(view, list): - kwargs["only"] = view - - super(UnlockSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/users.py b/CTFd/schemas/users.py deleted file mode 100644 index a398c375..00000000 --- a/CTFd/schemas/users.py +++ /dev/null @@ -1,367 +0,0 @@ -from marshmallow import ValidationError, post_dump, pre_load, validate -from marshmallow.fields import Nested -from marshmallow_sqlalchemy import field_for -from sqlalchemy.orm import load_only - -from CTFd.models import 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.user import get_current_user, is_admin -from CTFd.utils.validators import validate_country_code, validate_language - - -class UserSchema(ma.ModelSchema): - class Meta: - model = Users - include_fk = True - dump_only = ("id", "oauth_id", "created", "team_id") - load_only = ("password",) - - name = field_for( - Users, - "name", - required=True, - allow_none=False, - validate=[ - validate.Length(min=1, max=128, error="User names must not be empty") - ], - ) - email = field_for( - Users, - "email", - allow_none=False, - validate=[ - validate.Email("Emails must be a properly formatted email address"), - validate.Length(min=1, max=128, error="Emails must not be empty"), - ], - ) - website = field_for( - Users, - "website", - validate=[ - # This is a dirty hack to let website accept empty strings so you can remove your website - lambda website: validate.URL( - error="Websites must be a proper URL starting with http or https", - schemes={"http", "https"}, - )(website) - if website - else True - ], - ) - language = field_for(Users, "language", validate=[validate_language]) - country = field_for(Users, "country", validate=[validate_country_code]) - password = field_for(Users, "password", required=True, allow_none=False) - fields = Nested( - UserFieldEntriesSchema, partial=True, many=True, attribute="field_entries" - ) - - @pre_load - def validate_name(self, data): - name = data.get("name") - if name is None: - return - name = name.strip() - - existing_user = Users.query.filter_by(name=name).first() - current_user = get_current_user() - if is_admin(): - user_id = data.get("id") - if user_id: - if existing_user and existing_user.id != user_id: - raise ValidationError( - "User name has already been taken", field_names=["name"] - ) - else: - if existing_user: - if current_user: - if current_user.id != existing_user.id: - raise ValidationError( - "User name has already been taken", field_names=["name"] - ) - else: - raise ValidationError( - "User name has already been taken", field_names=["name"] - ) - else: - if name == current_user.name: - return data - else: - name_changes = get_config("name_changes", default=True) - if bool(name_changes) is False: - raise ValidationError( - "Name changes are disabled", field_names=["name"] - ) - if existing_user: - raise ValidationError( - "User name has already been taken", field_names=["name"] - ) - - @pre_load - def validate_email(self, data): - email = data.get("email") - if email is None: - return - email = email.strip() - - existing_user = Users.query.filter_by(email=email).first() - current_user = get_current_user() - if is_admin(): - user_id = data.get("id") - if user_id: - if existing_user and existing_user.id != user_id: - raise ValidationError( - "Email address has already been used", field_names=["email"] - ) - else: - if existing_user: - if current_user: - if current_user.id != existing_user.id: - raise ValidationError( - "Email address has already been used", - field_names=["email"], - ) - else: - raise ValidationError( - "Email address has already been used", field_names=["email"] - ) - else: - if email == current_user.email: - return data - else: - confirm = data.get("confirm") - - if bool(confirm) is False: - raise ValidationError( - "Please confirm your current password", field_names=["confirm"] - ) - - test = verify_password( - plaintext=confirm, ciphertext=current_user.password - ) - if test is False: - raise ValidationError( - "Your previous password is incorrect", field_names=["confirm"] - ) - - if existing_user: - raise ValidationError( - "Email address has already been used", field_names=["email"] - ) - if check_email_is_whitelisted(email) is False: - raise ValidationError( - "Email address is not from an allowed domain", - field_names=["email"], - ) - if get_config("verify_emails"): - current_user.verified = False - - @pre_load - def validate_password_confirmation(self, data): - password = data.get("password") - confirm = data.get("confirm") - target_user = get_current_user() - - if is_admin(): - pass - else: - if password and (bool(confirm) is False): - raise ValidationError( - "Please confirm your current password", field_names=["confirm"] - ) - - if password and confirm: - test = verify_password( - plaintext=confirm, ciphertext=target_user.password - ) - if test is True: - return data - else: - raise ValidationError( - "Your previous password is incorrect", field_names=["confirm"] - ) - else: - data.pop("password", None) - data.pop("confirm", None) - - @pre_load - def validate_fields(self, data): - """ - This validator is used to only allow users to update the field entry for their user. - It's not possible to exclude it because without the PK Marshmallow cannot load the right instance - """ - fields = data.get("fields") - if fields is None: - return - - current_user = get_current_user() - - if is_admin(): - user_id = data.get("id") - if user_id: - target_user = Users.query.filter_by(id=data["id"]).first() - else: - target_user = current_user - - # We are editting an existing user - if self.view == "admin" and self.instance: - target_user = self.instance - provided_ids = [] - for f in fields: - f.pop("id", None) - field_id = f.get("field_id") - - # # Check that we have an existing field for this. May be unnecessary b/c the foriegn key should enforce - field = UserFields.query.filter_by(id=field_id).first_or_404() - - # Get the existing field entry if one exists - entry = UserFieldEntries.query.filter_by( - field_id=field.id, user_id=target_user.id - ).first() - if entry: - f["id"] = entry.id - provided_ids.append(entry.id) - - # Extremely dirty hack to prevent deleting previously provided data. - # This needs a better soln. - entries = ( - UserFieldEntries.query.options(load_only("id")) - .filter_by(user_id=target_user.id) - .all() - ) - for entry in entries: - if entry.id not in provided_ids: - fields.append({"id": entry.id}) - else: - provided_ids = [] - for f in fields: - # Remove any existing set - f.pop("id", None) - field_id = f.get("field_id") - value = f.get("value") - - # # Check that we have an existing field for this. May be unnecessary b/c the foriegn key should enforce - field = UserFields.query.filter_by(id=field_id).first_or_404() - - # Get the existing field entry if one exists - entry = UserFieldEntries.query.filter_by( - field_id=field.id, user_id=current_user.id - ).first() - - if field.required is True: - if isinstance(value, str): - if value.strip() == "": - raise ValidationError( - f"Field '{field.name}' is required", - field_names=["fields"], - ) - - if field.editable is False and entry is not None: - raise ValidationError( - f"Field '{field.name}' cannot be editted", - field_names=["fields"], - ) - - if entry: - f["id"] = entry.id - provided_ids.append(entry.id) - - # Extremely dirty hack to prevent deleting previously provided data. - # This needs a better soln. - entries = ( - UserFieldEntries.query.options(load_only("id")) - .filter_by(user_id=current_user.id) - .all() - ) - for entry in entries: - if entry.id not in provided_ids: - fields.append({"id": entry.id}) - - @post_dump - def process_fields(self, data): - """ - Handle permissions levels for fields. - This is post_dump to manipulate JSON instead of the raw db object - - Admins can see all fields. - Users (self) can see their edittable and public fields - Public (user) can only see public fields - """ - # Gather all possible fields - removed_field_ids = [] - fields = UserFields.query.all() - - # Select fields for removal based on current view and properties of the field - for field in fields: - if self.view == "user": - if field.public is False: - removed_field_ids.append(field.id) - elif self.view == "self": - if field.editable is False and field.public is False: - removed_field_ids.append(field.id) - - # Rebuild fuilds - fields = data.get("fields") - if fields: - data["fields"] = [ - field for field in fields if field["field_id"] not in removed_field_ids - ] - - views = { - "user": [ - "website", - "name", - "country", - "affiliation", - "bracket", - "id", - "oauth_id", - "fields", - "team_id", - ], - "self": [ - "website", - "name", - "email", - "language", - "country", - "affiliation", - "bracket", - "id", - "oauth_id", - "password", - "fields", - "team_id", - ], - "admin": [ - "website", - "name", - "created", - "country", - "banned", - "email", - "language", - "affiliation", - "secret", - "bracket", - "hidden", - "id", - "oauth_id", - "password", - "type", - "verified", - "fields", - "team_id", - ], - } - - def __init__(self, view=None, *args, **kwargs): - if view: - if isinstance(view, string_types): - kwargs["only"] = self.views[view] - elif isinstance(view, list): - kwargs["only"] = view - self.view = view - - super(UserSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/scoreboard.py b/CTFd/scoreboard.py deleted file mode 100644 index 261813b5..00000000 --- a/CTFd/scoreboard.py +++ /dev/null @@ -1,29 +0,0 @@ -from flask import Blueprint, render_template - -from CTFd.utils import config -from CTFd.utils.config.visibility import scores_visible -from CTFd.utils.decorators.visibility import ( - check_account_visibility, - check_score_visibility, -) -from CTFd.utils.helpers import get_infos -from CTFd.utils.scores import get_standings -from CTFd.utils.user import is_admin - -scoreboard = Blueprint("scoreboard", __name__) - - -@scoreboard.route("/scoreboard") -@check_account_visibility -@check_score_visibility -def listing(): - infos = get_infos() - - if config.is_scoreboard_frozen(): - infos.append("Scoreboard has been frozen") - - if is_admin() is True and scores_visible() is False: - infos.append("Scores are not currently visible to users") - - standings = get_standings() - return render_template("scoreboard.html", standings=standings, infos=infos) diff --git a/CTFd/teams.py b/CTFd/teams.py deleted file mode 100644 index 1e6fcfb0..00000000 --- a/CTFd/teams.py +++ /dev/null @@ -1,379 +0,0 @@ -from flask import Blueprint, abort, redirect, render_template, request, url_for - -from CTFd.cache import clear_team_session, clear_user_session -from CTFd.exceptions import TeamTokenExpiredException, TeamTokenInvalidException -from CTFd.models import TeamFieldEntries, TeamFields, Teams, db -from CTFd.utils import config, get_config, validators -from CTFd.utils.crypto import verify_password -from CTFd.utils.decorators import authed_only, ratelimit, registered_only -from CTFd.utils.decorators.modes import require_team_mode -from CTFd.utils.decorators.visibility import ( - check_account_visibility, - check_score_visibility, -) -from CTFd.utils.helpers import get_errors, get_infos -from CTFd.utils.humanize.words import pluralize -from CTFd.utils.user import get_current_user, get_current_user_attrs - -teams = Blueprint("teams", __name__) - - -@teams.route("/teams") -@check_account_visibility -@require_team_mode -def listing(): - q = request.args.get("q") - field = request.args.get("field", "name") - filters = [] - - if field not in ("name", "affiliation", "website"): - field = "name" - - if q: - filters.append(getattr(Teams, field).like("%{}%".format(q))) - - teams = ( - Teams.query.filter_by(hidden=False, banned=False) - .filter(*filters) - .order_by(Teams.id.asc()) - .paginate(per_page=50) - ) - - args = dict(request.args) - args.pop("page", 1) - - return render_template( - "teams/teams.html", - teams=teams, - prev_page=url_for(request.endpoint, page=teams.prev_num, **args), - next_page=url_for(request.endpoint, page=teams.next_num, **args), - q=q, - field=field, - ) - - -@teams.route("/teams/invite", methods=["GET", "POST"]) -@registered_only -@require_team_mode -def invite(): - infos = get_infos() - errors = get_errors() - code = request.args.get("code") - - if code is None: - abort(404) - - user = get_current_user_attrs() - if user.team_id: - errors.append("You are already in a team. You cannot join another.") - - try: - team = Teams.load_invite_code(code) - except TeamTokenExpiredException: - abort(403, description="This invite URL has expired") - except TeamTokenInvalidException: - abort(403, description="This invite URL is invalid") - - team_size_limit = get_config("team_size", default=0) - - if request.method == "GET": - if team_size_limit: - infos.append( - "Teams are limited to {limit} member{plural}".format( - limit=team_size_limit, plural=pluralize(number=team_size_limit) - ) - ) - - return render_template( - "teams/invite.html", team=team, infos=infos, errors=errors - ) - - if request.method == "POST": - if errors: - return ( - render_template( - "teams/invite.html", team=team, infos=infos, errors=errors - ), - 403, - ) - - if team_size_limit and len(team.members) >= team_size_limit: - errors.append( - "{name} has already reached the team size limit of {limit}".format( - name=team.name, limit=team_size_limit - ) - ) - return ( - render_template( - "teams/invite.html", team=team, infos=infos, errors=errors - ), - 403, - ) - - user = get_current_user() - user.team_id = team.id - db.session.commit() - - clear_user_session(user_id=user.id) - clear_team_session(team_id=team.id) - - return redirect(url_for("challenges.listing")) - - -@teams.route("/teams/join", methods=["GET", "POST"]) -@authed_only -@require_team_mode -@ratelimit(method="POST", limit=10, interval=5) -def join(): - infos = get_infos() - errors = get_errors() - - user = get_current_user_attrs() - if user.team_id: - errors.append("You are already in a team. You cannot join another.") - - if request.method == "GET": - team_size_limit = get_config("team_size", default=0) - if team_size_limit: - plural = "" if team_size_limit == 1 else "s" - infos.append( - "Teams are limited to {limit} member{plural}".format( - limit=team_size_limit, plural=plural - ) - ) - return render_template("teams/join_team.html", infos=infos, errors=errors) - - if request.method == "POST": - teamname = request.form.get("name") - passphrase = request.form.get("password", "").strip() - - team = Teams.query.filter_by(name=teamname).first() - - if errors: - return ( - render_template("teams/join_team.html", infos=infos, errors=errors), - 403, - ) - - if team and verify_password(passphrase, team.password): - team_size_limit = get_config("team_size", default=0) - if team_size_limit and len(team.members) >= team_size_limit: - errors.append( - "{name} has already reached the team size limit of {limit}".format( - name=team.name, limit=team_size_limit - ) - ) - return render_template( - "teams/join_team.html", infos=infos, errors=errors - ) - - user = get_current_user() - user.team_id = team.id - db.session.commit() - - if len(team.members) == 1: - team.captain_id = user.id - db.session.commit() - - clear_user_session(user_id=user.id) - clear_team_session(team_id=team.id) - - return redirect(url_for("challenges.listing")) - else: - errors.append("That information is incorrect") - return render_template("teams/join_team.html", infos=infos, errors=errors) - - -@teams.route("/teams/new", methods=["GET", "POST"]) -@authed_only -@require_team_mode -def new(): - infos = get_infos() - errors = get_errors() - - if bool(get_config("team_creation", default=True)) is False: - abort( - 403, - description="Team creation is currently disabled. Please join an existing team.", - ) - - num_teams_limit = int(get_config("num_teams", default=0)) - num_teams = Teams.query.filter_by(banned=False, hidden=False).count() - if num_teams_limit and num_teams >= num_teams_limit: - abort( - 403, - description=f"Reached the maximum number of teams ({num_teams_limit}). Please join an existing team.", - ) - - user = get_current_user_attrs() - if user.team_id: - errors.append("You are already in a team. You cannot join another.") - - if request.method == "GET": - team_size_limit = get_config("team_size", default=0) - if team_size_limit: - plural = "" if team_size_limit == 1 else "s" - infos.append( - "Teams are limited to {limit} member{plural}".format( - limit=team_size_limit, plural=plural - ) - ) - return render_template("teams/new_team.html", infos=infos, errors=errors) - - elif request.method == "POST": - teamname = request.form.get("name", "").strip() - passphrase = request.form.get("password", "").strip() - - website = request.form.get("website") - affiliation = request.form.get("affiliation") - - user = get_current_user() - - existing_team = Teams.query.filter_by(name=teamname).first() - if existing_team: - errors.append("That team name is already taken") - if not teamname: - errors.append("That team name is invalid") - - # Process additional user fields - fields = {} - for field in TeamFields.query.all(): - fields[field.id] = field - - entries = {} - for field_id, field in fields.items(): - value = request.form.get(f"fields[{field_id}]", "").strip() - if field.required is True and (value is None or value == ""): - errors.append("Please provide all required fields") - break - - # Handle special casing of existing profile fields - if field.name.lower() == "affiliation": - affiliation = value - break - elif field.name.lower() == "website": - website = value - break - - if field.field_type == "boolean": - entries[field_id] = bool(value) - else: - entries[field_id] = value - - if website: - valid_website = validators.validate_url(website) - else: - valid_website = True - - if affiliation: - valid_affiliation = len(affiliation) < 128 - else: - valid_affiliation = True - - if valid_website is False: - errors.append("Websites must be a proper URL starting with http or https") - if valid_affiliation is False: - errors.append("Please provide a shorter affiliation") - - if errors: - return render_template("teams/new_team.html", errors=errors), 403 - - # Hide the created team if the creator is an admin - hidden = False - if user.type == "admin": - hidden = True - - team = Teams( - name=teamname, password=passphrase, captain_id=user.id, hidden=hidden - ) - - if website: - team.website = website - if affiliation: - team.affiliation = affiliation - - db.session.add(team) - db.session.commit() - - for field_id, value in entries.items(): - entry = TeamFieldEntries(field_id=field_id, value=value, team_id=team.id) - db.session.add(entry) - db.session.commit() - - user.team_id = team.id - db.session.commit() - - clear_user_session(user_id=user.id) - clear_team_session(team_id=team.id) - - return redirect(url_for("challenges.listing")) - - -@teams.route("/team") -@authed_only -@require_team_mode -def private(): - infos = get_infos() - errors = get_errors() - - user = get_current_user() - if not user.team_id: - return render_template("teams/team_enrollment.html") - - team_id = user.team_id - - team = Teams.query.filter_by(id=team_id).first_or_404() - solves = team.get_solves() - awards = team.get_awards() - - place = team.place - score = team.score - - if config.is_scoreboard_frozen(): - infos.append("Scoreboard has been frozen") - - return render_template( - "teams/private.html", - solves=solves, - awards=awards, - user=user, - team=team, - score=score, - place=place, - score_frozen=config.is_scoreboard_frozen(), - infos=infos, - errors=errors, - ) - - -@teams.route("/teams/") -@check_account_visibility -@check_score_visibility -@require_team_mode -def public(team_id): - infos = get_infos() - errors = get_errors() - team = Teams.query.filter_by(id=team_id, banned=False, hidden=False).first_or_404() - solves = team.get_solves() - awards = team.get_awards() - - place = team.place - score = team.score - - if errors: - return render_template("teams/public.html", team=team, errors=errors) - - if config.is_scoreboard_frozen(): - infos.append("Scoreboard has been frozen") - - return render_template( - "teams/public.html", - solves=solves, - awards=awards, - team=team, - score=score, - place=place, - score_frozen=config.is_scoreboard_frozen(), - infos=infos, - errors=errors, - ) diff --git a/CTFd/themes/admin/assets/css/admin.scss b/CTFd/themes/admin/assets/css/admin.scss deleted file mode 100644 index 9630d5e7..00000000 --- a/CTFd/themes/admin/assets/css/admin.scss +++ /dev/null @@ -1,94 +0,0 @@ -@import "includes/sticky-footer.css"; - -#score-graph { - min-height: 400px; - display: block; - clear: both; -} - -#solves-graph { - display: block; - height: 350px; -} - -#keys-pie-graph { - min-height: 400px; - display: block; -} - -#categories-pie-graph { - min-height: 400px; - display: block; -} - -#solve-percentages-graph { - min-height: 400px; - display: block; -} - -#score-distribution-graph { - min-height: 400px; - display: block; -} - -.no-decoration { - color: inherit !important; - text-decoration: none !important; -} - -.no-decoration:hover { - color: inherit !important; - text-decoration: none !important; -} - -.table td, -.table th { - vertical-align: inherit; -} - -pre { - white-space: pre-wrap; - margin: 0; - padding: 0; -} - -.form-control { - position: relative; - display: block; - /*padding: 0.8em;*/ - border-radius: 0; - /*background: #f0f0f0;*/ - /*color: #aaa;*/ - font-weight: 400; - font-family: "Avenir Next", "Helvetica Neue", Helvetica, Arial, sans-serif; - -webkit-appearance: none; -} - -tbody tr:hover { - background-color: rgba(0, 0, 0, 0.1) !important; -} - -[data-href] { - cursor: pointer; -} - -.sort-col { - cursor: pointer; -} - -input[type="checkbox"] { - cursor: pointer; -} - -.card-radio:checked + .card { - background-color: transparent !important; - border-color: #a3d39c; - box-shadow: 0 0 0 0.1rem #a3d39c; - transition: background-color 0.3s, border-color 0.3s; -} -.card-radio:checked + .card .card-radio-clone { - visibility: visible !important; -} -.card:hover { - cursor: pointer; -} diff --git a/CTFd/themes/admin/assets/css/challenge-board.scss b/CTFd/themes/admin/assets/css/challenge-board.scss deleted file mode 100644 index 2f2df40a..00000000 --- a/CTFd/themes/admin/assets/css/challenge-board.scss +++ /dev/null @@ -1,66 +0,0 @@ -.chal-desc { - padding-left: 30px; - padding-right: 30px; - font-size: 14px; -} - -.chal-desc img { - max-width: 100%; - height: auto; -} - -.modal-content { - border-radius: 0px; - max-width: 1000px; - padding: 1em; - margin: 0 auto; -} - -.btn-info { - background-color: #5b7290 !important; -} - -.badge-info { - background-color: #5b7290 !important; -} - -.challenge-button { - box-shadow: 3px 3px 3px grey; -} - -.solved-challenge { - background-color: #37d63e !important; - opacity: 0.4; - border: none; -} - -.corner-button-check { - margin-top: -10px; - margin-right: 25px; - position: absolute; - right: 0; -} - -.key-submit .btn { - height: 51px; -} - -#challenge-window .form-control { - position: relative; - display: block; - padding: 0.8em; - border-radius: 0; - background: #f0f0f0; - color: #aaa; - font-weight: 400; - font-family: "Avenir Next", "Helvetica Neue", Helvetica, Arial, sans-serif; - -webkit-appearance: none; - height: auto !important; -} - -#challenge-window .form-control:focus { - background-color: transparent; - border-color: #a3d39c; - box-shadow: 0 0 0 0.1rem #a3d39c; - transition: background-color 0.3s, border-color 0.3s; -} diff --git a/CTFd/themes/admin/assets/css/codemirror.scss b/CTFd/themes/admin/assets/css/codemirror.scss deleted file mode 100644 index 97b02ddb..00000000 --- a/CTFd/themes/admin/assets/css/codemirror.scss +++ /dev/null @@ -1,6 +0,0 @@ -@import "~codemirror/lib/codemirror.css"; -@import "includes/easymde.scss"; -.CodeMirror.cm-s-default { - font-size: 12px; - border: 1px solid lightgray; -} diff --git a/CTFd/themes/admin/assets/css/includes/easymde.scss b/CTFd/themes/admin/assets/css/includes/easymde.scss deleted file mode 100644 index fa6f8581..00000000 --- a/CTFd/themes/admin/assets/css/includes/easymde.scss +++ /dev/null @@ -1,381 +0,0 @@ -.CodeMirror.cm-s-easymde { - box-sizing: border-box; - height: auto; - border: 1px solid lightgray; - padding: 10px; - font: inherit; - z-index: 0; - word-wrap: break-word; -} - -.CodeMirror-scroll { - overflow-y: hidden; - overflow-x: auto; -} - -.editor-toolbar { - position: relative; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - -o-user-select: none; - user-select: none; - padding: 0 10px; - border-top: 1px solid #bbb; - border-left: 1px solid #bbb; - border-right: 1px solid #bbb; -} - -.editor-toolbar:after, -.editor-toolbar:before { - display: block; - content: " "; - height: 1px; -} - -.editor-toolbar:before { - margin-bottom: 8px; -} - -.editor-toolbar:after { - margin-top: 8px; -} - -.editor-toolbar.fullscreen { - width: 100%; - height: 50px; - padding-top: 10px; - padding-bottom: 10px; - box-sizing: border-box; - background: #fff; - border: 0; - position: fixed; - top: 0; - left: 0; - opacity: 1; - z-index: 9; -} - -.editor-toolbar.fullscreen::before { - width: 20px; - height: 50px; - background: -moz-linear-gradient( - left, - rgba(255, 255, 255, 1) 0%, - rgba(255, 255, 255, 0) 100% - ); - background: -webkit-gradient( - linear, - left top, - right top, - color-stop(0%, rgba(255, 255, 255, 1)), - color-stop(100%, rgba(255, 255, 255, 0)) - ); - background: -webkit-linear-gradient( - left, - rgba(255, 255, 255, 1) 0%, - rgba(255, 255, 255, 0) 100% - ); - background: -o-linear-gradient( - left, - rgba(255, 255, 255, 1) 0%, - rgba(255, 255, 255, 0) 100% - ); - background: -ms-linear-gradient( - left, - rgba(255, 255, 255, 1) 0%, - rgba(255, 255, 255, 0) 100% - ); - background: linear-gradient( - to right, - rgba(255, 255, 255, 1) 0%, - rgba(255, 255, 255, 0) 100% - ); - position: fixed; - top: 0; - left: 0; - margin: 0; - padding: 0; -} - -.editor-toolbar.fullscreen::after { - width: 20px; - height: 50px; - background: -moz-linear-gradient( - left, - rgba(255, 255, 255, 0) 0%, - rgba(255, 255, 255, 1) 100% - ); - background: -webkit-gradient( - linear, - left top, - right top, - color-stop(0%, rgba(255, 255, 255, 0)), - color-stop(100%, rgba(255, 255, 255, 1)) - ); - background: -webkit-linear-gradient( - left, - rgba(255, 255, 255, 0) 0%, - rgba(255, 255, 255, 1) 100% - ); - background: -o-linear-gradient( - left, - rgba(255, 255, 255, 0) 0%, - rgba(255, 255, 255, 1) 100% - ); - background: -ms-linear-gradient( - left, - rgba(255, 255, 255, 0) 0%, - rgba(255, 255, 255, 1) 100% - ); - background: linear-gradient( - to right, - rgba(255, 255, 255, 0) 0%, - rgba(255, 255, 255, 1) 100% - ); - position: fixed; - top: 0; - right: 0; - margin: 0; - padding: 0; -} - -.editor-toolbar button, -.editor-toolbar .easymde-dropdown { - background: transparent; - display: inline-block; - text-align: center; - text-decoration: none !important; - height: 30px; - margin: 0; - padding: 0; - border: 1px solid transparent; - border-radius: 3px; - cursor: pointer; -} - -.editor-toolbar button { - width: 30px; -} - -.editor-toolbar button.active, -.editor-toolbar button:hover { - background: #fcfcfc; - border-color: #95a5a6; -} - -.editor-toolbar i.separator { - display: inline-block; - width: 0; - border-left: 1px solid #d9d9d9; - border-right: 1px solid #fff; - color: transparent; - text-indent: -10px; - margin: 0 6px; -} - -.editor-toolbar button:after { - font-family: Arial, "Helvetica Neue", Helvetica, sans-serif; - font-size: 65%; - vertical-align: text-bottom; - position: relative; - top: 2px; -} - -.editor-toolbar button.heading-1:after { - content: "1"; -} - -.editor-toolbar button.heading-2:after { - content: "2"; -} - -.editor-toolbar button.heading-3:after { - content: "3"; -} - -.editor-toolbar button.heading-bigger:after { - content: "â–²"; -} - -.editor-toolbar button.heading-smaller:after { - content: "â–¼"; -} - -.editor-toolbar.disabled-for-preview button:not(.no-disable) { - opacity: 0.6; - pointer-events: none; -} - -@media only screen and (max-width: 700px) { - .editor-toolbar i.no-mobile { - display: none; - } -} - -.editor-statusbar { - padding: 8px 10px; - font-size: 12px; - color: #959694; - text-align: right; -} - -.editor-statusbar span { - display: inline-block; - min-width: 4em; - margin-left: 1em; -} - -.editor-statusbar .lines:before { - content: "lines: "; -} - -.editor-statusbar .words:before { - content: "words: "; -} - -.editor-statusbar .characters:before { - content: "characters: "; -} - -.editor-preview-full { - position: absolute; - width: 100%; - height: 100%; - top: 0; - left: 0; - z-index: 7; - overflow: auto; - display: none; - box-sizing: border-box; -} - -.editor-preview-side { - position: fixed; - bottom: 0; - width: 50%; - top: 50px; - right: 0; - z-index: 9; - overflow: auto; - display: none; - box-sizing: border-box; - border: 1px solid #ddd; - word-wrap: break-word; -} - -.editor-preview-active-side { - display: block; -} - -.editor-preview-active { - display: block; -} - -.editor-preview { - padding: 10px; - background: #fafafa; -} - -.editor-preview > p { - margin-top: 0; -} - -.editor-preview pre { - background: #eee; - margin-bottom: 10px; -} - -.editor-preview table td, -.editor-preview table th { - border: 1px solid #ddd; - padding: 5px; -} - -.cm-s-easymde .cm-tag { - color: #63a35c; -} - -.cm-s-easymde .cm-attribute { - color: #795da3; -} - -.cm-s-easymde .cm-string { - color: #183691; -} - -.cm-s-easymde .cm-header-1 { - font-size: 200%; - line-height: 200%; -} - -.cm-s-easymde .cm-header-2 { - font-size: 160%; - line-height: 160%; -} - -.cm-s-easymde .cm-header-3 { - font-size: 125%; - line-height: 125%; -} - -.cm-s-easymde .cm-header-4 { - font-size: 110%; - line-height: 110%; -} - -.cm-s-easymde .cm-comment { - background: rgba(0, 0, 0, 0.05); - border-radius: 2px; -} - -.cm-s-easymde .cm-link { - color: #7f8c8d; -} - -.cm-s-easymde .cm-url { - color: #aab2b3; -} - -.cm-s-easymde .cm-quote { - color: #7f8c8d; - font-style: italic; -} - -.editor-toolbar .easymde-dropdown { - position: relative; - background: linear-gradient( - to bottom right, - #fff 0%, - #fff 84%, - #333 50%, - #333 100% - ); - border-radius: 0; - border: 1px solid #fff; -} - -.editor-toolbar .easymde-dropdown:hover { - background: linear-gradient( - to bottom right, - #fff 0%, - #fff 84%, - #333 50%, - #333 100% - ); -} - -.easymde-dropdown-content { - display: none; - position: absolute; - background-color: #f9f9f9; - box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.2); - padding: 8px; - z-index: 2; - top: 30px; -} - -.easymde-dropdown:active .easymde-dropdown-content, -.easymde-dropdown:focus .easymde-dropdown-content { - display: block; -} diff --git a/CTFd/themes/admin/assets/css/includes/sticky-footer.css b/CTFd/themes/admin/assets/css/includes/sticky-footer.css deleted file mode 100644 index 940b9dcd..00000000 --- a/CTFd/themes/admin/assets/css/includes/sticky-footer.css +++ /dev/null @@ -1,30 +0,0 @@ -/* Sticky footer styles --------------------------------------------------- */ -html { - position: relative; - min-height: 100%; -} - -body { - margin-bottom: 60px; /* Margin bottom by footer height */ -} - -.footer { - position: absolute; - - /* prevent scrollbars from showing on pages that don't use the full page height */ - bottom: 1px; - - width: 100%; - - /* Set the fixed height of the footer here */ - height: 60px; - - /* Override line-height from core because we have two lines in the admin panel */ - line-height: normal !important; - - /* Avoid covering things */ - z-index: -20; - - /*background-color: #f5f5f5;*/ -} diff --git a/CTFd/themes/admin/assets/js/challenges/challenge.js b/CTFd/themes/admin/assets/js/challenges/challenge.js deleted file mode 100644 index 05c9deee..00000000 --- a/CTFd/themes/admin/assets/js/challenges/challenge.js +++ /dev/null @@ -1,204 +0,0 @@ -import $ from "jquery"; -import { ezToast, ezQuery } from "core/ezq"; -import { htmlEntities } from "core/utils"; -import CTFd from "core/CTFd"; -import nunjucks from "nunjucks"; - -function renderSubmissionResponse(response, cb) { - const result = response.data; - - const result_message = $("#result-message"); - const result_notification = $("#result-notification"); - const answer_input = $("#submission-input"); - result_notification.removeClass(); - result_message.text(result.message); - - if (result.status === "authentication_required") { - window.location = - CTFd.config.urlRoot + - "/login?next=" + - CTFd.config.urlRoot + - window.location.pathname + - window.location.hash; - return; - } else if (result.status === "incorrect") { - // Incorrect key - result_notification.addClass( - "alert alert-danger alert-dismissable text-center" - ); - result_notification.slideDown(); - - answer_input.removeClass("correct"); - answer_input.addClass("wrong"); - setTimeout(function() { - answer_input.removeClass("wrong"); - }, 3000); - } else if (result.status === "correct") { - // Challenge Solved - result_notification.addClass( - "alert alert-success alert-dismissable text-center" - ); - result_notification.slideDown(); - - $(".challenge-solves").text( - parseInt( - $(".challenge-solves") - .text() - .split(" ")[0] - ) + - 1 + - " Solves" - ); - - answer_input.val(""); - answer_input.removeClass("wrong"); - answer_input.addClass("correct"); - } else if (result.status === "already_solved") { - // Challenge already solved - result_notification.addClass( - "alert alert-info alert-dismissable text-center" - ); - result_notification.slideDown(); - - answer_input.addClass("correct"); - } else if (result.status === "paused") { - // CTF is paused - result_notification.addClass( - "alert alert-warning alert-dismissable text-center" - ); - result_notification.slideDown(); - } else if (result.status === "ratelimited") { - // Keys per minute too high - result_notification.addClass( - "alert alert-warning alert-dismissable text-center" - ); - result_notification.slideDown(); - - answer_input.addClass("too-fast"); - setTimeout(function() { - answer_input.removeClass("too-fast"); - }, 3000); - } - setTimeout(function() { - $(".alert").slideUp(); - $("#submit-key").removeClass("disabled-button"); - $("#submit-key").prop("disabled", false); - }, 3000); - - if (cb) { - cb(result); - } -} - -$(() => { - $(".preview-challenge").click(function(_event) { - window.challenge = new Object(); - $.get( - CTFd.config.urlRoot + "/api/v1/challenges/" + window.CHALLENGE_ID, - function(response) { - const challenge_data = response.data; - challenge_data["solves"] = null; - - $.getScript( - CTFd.config.urlRoot + challenge_data.type_data.scripts.view, - function() { - $.get( - CTFd.config.urlRoot + challenge_data.type_data.templates.view, - function(template_data) { - $("#challenge-window").empty(); - const template = nunjucks.compile(template_data); - window.challenge.data = challenge_data; - window.challenge.preRender(); - - challenge_data["description"] = window.challenge.render( - challenge_data["description"] - ); - challenge_data["script_root"] = CTFd.config.urlRoot; - - $("#challenge-window").append(template.render(challenge_data)); - - $(".nav-tabs a").click(function(event) { - event.preventDefault(); - $(this).tab("show"); - }); - - // Handle modal toggling - $("#challenge-window").on("hide.bs.modal", function(_event) { - $("#submission-input").removeClass("wrong"); - $("#submission-input").removeClass("correct"); - $("#incorrect-key").slideUp(); - $("#correct-key").slideUp(); - $("#already-solved").slideUp(); - $("#too-fast").slideUp(); - }); - - $("#submit-key").click(function(event) { - event.preventDefault(); - $("#submit-key").addClass("disabled-button"); - $("#submit-key").prop("disabled", true); - window.challenge.submit(function(data) { - renderSubmissionResponse(data); - }, true); - // Preview passed as true - }); - - $("#submission-input").keyup(function(event) { - if (event.keyCode == 13) { - $("#submit-key").click(); - } - }); - - window.challenge.postRender(); - window.location.replace( - window.location.href.split("#")[0] + "#preview" - ); - - $("#challenge-window").modal(); - } - ); - } - ); - } - ); - }); - - $(".delete-challenge").click(function(_event) { - ezQuery({ - title: "Delete Challenge", - body: "Are you sure you want to delete {0}".format( - "" + htmlEntities(window.CHALLENGE_NAME) + "" - ), - success: function() { - CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID, { - method: "DELETE" - }).then(function(response) { - if (response.success) { - window.location = CTFd.config.urlRoot + "/admin/challenges"; - } - }); - } - }); - }); - - $("#challenge-update-container > form").submit(function(event) { - event.preventDefault(); - const params = $(event.target).serializeJSON(true); - - CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID, { - method: "PATCH", - credentials: "same-origin", - headers: { - Accept: "application/json", - "Content-Type": "application/json" - }, - body: JSON.stringify(params) - }).then(function(data) { - if (data.success) { - ezToast({ - title: "Success", - body: "Your challenge has been updated!" - }); - } - }); - }); -}); diff --git a/CTFd/themes/admin/assets/js/challenges/new.js b/CTFd/themes/admin/assets/js/challenges/new.js deleted file mode 100644 index 18995862..00000000 --- a/CTFd/themes/admin/assets/js/challenges/new.js +++ /dev/null @@ -1,76 +0,0 @@ -import CTFd from "core/CTFd"; -import nunjucks from "nunjucks"; -import $ from "jquery"; - -window.challenge = new Object(); - -function loadChalTemplate(challenge) { - $.getScript(CTFd.config.urlRoot + challenge.scripts.view, function() { - $.get(CTFd.config.urlRoot + challenge.templates.create, function( - template_data - ) { - const template = nunjucks.compile(template_data); - $("#create-chal-entry-div").html( - template.render({ - nonce: CTFd.config.csrfNonce, - script_root: CTFd.config.urlRoot - }) - ); - - $.getScript(CTFd.config.urlRoot + challenge.scripts.create, function() { - $("#create-chal-entry-div form").submit(function(event) { - event.preventDefault(); - const params = $("#create-chal-entry-div form").serializeJSON(); - CTFd.fetch("/api/v1/challenges", { - method: "POST", - credentials: "same-origin", - headers: { - Accept: "application/json", - "Content-Type": "application/json" - }, - body: JSON.stringify(params) - }).then(function(response) { - if (response.success) { - window.location = - CTFd.config.urlRoot + "/admin/challenges/" + response.data.id; - } - }); - }); - }); - }); - }); -} - -$.get(CTFd.config.urlRoot + "/api/v1/challenges/types", function(response) { - $("#create-chals-select").empty(); - const data = response.data; - const chal_type_amt = Object.keys(data).length; - if (chal_type_amt > 1) { - const option = ""; - $("#create-chals-select").append(option); - for (const key in data) { - const challenge = data[key]; - const option = $("