init CTFd source
Some checks are pending
Linting / Linting (3.11) (push) Waiting to run
Mirror core-theme / mirror (push) Waiting to run

This commit is contained in:
gkr
2025-12-25 09:39:21 +08:00
commit 2e06f92c64
1047 changed files with 150349 additions and 0 deletions

9
.codecov.yml Normal file
View File

@@ -0,0 +1,9 @@
coverage:
status:
project:
default:
# Fail the status if coverage drops by >= 1%
threshold: 1
patch:
default:
threshold: 1

19
.dockerignore Normal file
View File

@@ -0,0 +1,19 @@
CTFd/logs/*.log
CTFd/static/uploads
CTFd/uploads
CTFd/*.db
CTFd/uploads/**/*
.ctfd_secret_key
.data
.git
.codecov.yml
.dockerignore
.github
.gitignore
.prettierignore
.travis.yml
**/node_modules
**/*.pyc
**/__pycache__
.venv*
venv*

18
.eslintrc.js Normal file
View File

@@ -0,0 +1,18 @@
module.exports = {
"env": {
"browser": true,
"es6": true
},
"extends": "eslint:recommended",
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly"
},
"parserOptions": {
"ecmaVersion": 2018,
"sourceType": "module"
},
"rules": {
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
}
};

2
.flaskenv Normal file
View File

@@ -0,0 +1,2 @@
FLASK_ENV=development
FLASK_RUN_PORT=4000

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.sh text eol=lf

3
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
# These are supported funding model platforms
github: CTFd

19
.github/ISSUE_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,19 @@
<!--
If this is a bug report please fill out the template below.
If this is a feature request please describe the behavior that you'd like to see.
-->
**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**

46
.github/workflows/docker-build.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: Docker build image on release
on:
release:
types: [published]
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Set repo name lowercase
id: repo
uses: ASzc/change-string-case-action@v2
with:
string: ${{ github.repository }}
- name: Checkout
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ steps.repo.outputs.lowercase }}:latest
ghcr.io/${{ steps.repo.outputs.lowercase }}:latest
${{ steps.repo.outputs.lowercase }}:${{ github.event.release.tag_name }}
ghcr.io/${{ steps.repo.outputs.lowercase }}:${{ github.event.release.tag_name }}

47
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
---
name: Linting
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.11']
name: Linting
steps:
- uses: actions/checkout@v2
- name: Setup python
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
architecture: x64
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -r linting.txt
sudo yarn --cwd CTFd/themes/admin install --non-interactive
sudo yarn global add prettier@3.2.5
- name: Lint
run: make lint
env:
TESTING_DATABASE_URL: 'sqlite://'
- name: Lint Dockerfile
uses: brpaz/hadolint-action@master
with:
dockerfile: "Dockerfile"
- name: Lint docker-compose
run: |
docker compose -f docker-compose.yml config
- name: Lint translations
run: |
make translations-lint

59
.github/workflows/mariadb.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
---
name: CTFd MariaDB CI
on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
build:
runs-on: ubuntu-latest
services:
mariadb:
image: mariadb:10.11
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: ctfd
ports:
- 3306
redis:
image: redis
ports:
- 6379:6379
strategy:
matrix:
python-version: ['3.11']
name: Python ${{ matrix.python-version }}
steps:
- uses: actions/checkout@v2
- name: Setup python
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
architecture: x64
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -r development.txt
sudo yarn install --non-interactive
- name: Test
run: |
sudo rm -f /etc/boto.cfg
make test
env:
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
TESTING_DATABASE_URL: mysql+pymysql://root:password@localhost:${{ job.services.mariadb.ports[3306] }}/ctfd
- name: Codecov
uses: codecov/codecov-action@v4
with:
file: ./coverage.xml

30
.github/workflows/mirror-core-theme.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Mirror core-theme
on:
push:
branches:
- main
workflow_dispatch:
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # need full history for subtree
- name: Setup SSH for deploy key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.CORE_THEME_DEPLOY_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan github.com >> ~/.ssh/known_hosts
- name: Setup git identity
run: |
git config user.name "github-actions[bot]"
git config user.email "username@users.noreply.github.com"
- name: Push subtree
run: |
git subtree push --prefix="CTFd/themes/core" "git@github.com:CTFd/core-theme.git" main

59
.github/workflows/mysql.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
---
name: CTFd MySQL CI
on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
build:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:5.7
env:
MYSQL_ROOT_PASSWORD: password
ports:
- 3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
redis:
image: redis
ports:
- 6379:6379
strategy:
matrix:
python-version: ['3.11']
name: Python ${{ matrix.python-version }}
steps:
- uses: actions/checkout@v2
- name: Setup python
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
architecture: x64
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -r development.txt
sudo yarn install --non-interactive
- name: Test
run: |
sudo rm -f /etc/boto.cfg
make test
env:
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
TESTING_DATABASE_URL: mysql+pymysql://root:password@localhost:${{ job.services.mysql.ports[3306] }}/ctfd
- name: Codecov
uses: codecov/codecov-action@v5
with:
file: ./coverage.xml

59
.github/workflows/mysql8.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
---
name: CTFd MySQL 8.0 CI
on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
build:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: password
ports:
- 3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
redis:
image: redis
ports:
- 6379:6379
strategy:
matrix:
python-version: ['3.11']
name: Python ${{ matrix.python-version }}
steps:
- uses: actions/checkout@v2
- name: Setup python
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
architecture: x64
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -r development.txt
sudo yarn install --non-interactive
- name: Test
run: |
sudo rm -f /etc/boto.cfg
make test
env:
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
TESTING_DATABASE_URL: mysql+pymysql://root:password@localhost:${{ job.services.mysql.ports[3306] }}/ctfd
- name: Codecov
uses: codecov/codecov-action@v5
with:
file: ./coverage.xml

67
.github/workflows/postgres.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
---
name: CTFd Postgres CI
on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
build:
runs-on: ubuntu-latest
services:
postgres:
image: postgres
ports:
- 5432:5432
env:
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_DB: ctfd
POSTGRES_PASSWORD: password
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
strategy:
matrix:
python-version: ['3.11']
name: Python ${{ matrix.python-version }}
steps:
- uses: actions/checkout@v2
- name: Setup python
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
architecture: x64
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -r development.txt
sudo yarn install --non-interactive
- name: Test
run: |
sudo rm -f /etc/boto.cfg
make test
env:
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
TESTING_DATABASE_URL: postgres://postgres:password@localhost:${{ job.services.postgres.ports[5432] }}/ctfd
- name: Codecov
uses: codecov/codecov-action@v5
with:
file: ./coverage.xml

49
.github/workflows/sqlite.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
---
name: CTFd SQLite CI
on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.11']
name: Python ${{ matrix.python-version }}
steps:
- uses: actions/checkout@v2
- name: Setup python
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
architecture: x64
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -r development.txt
sudo yarn install --non-interactive
sudo yarn global add prettier@1.17.0
- name: Test
run: |
sudo rm -f /etc/boto.cfg
make test
env:
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
TESTING_DATABASE_URL: 'sqlite://'
- name: Codecov
uses: codecov/codecov-action@v5
with:
file: ./coverage.xml

38
.github/workflows/verify-themes.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
---
name: Theme Verification
on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
build:
runs-on: ubuntu-latest
name: Theme Verification
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v4
with:
node-version: 20.19
- name: Verify admin theme
run: |
pwd
yarn install --non-interactive
yarn verify
working-directory: ./CTFd/themes/admin
# TODO: Replace in 4.0 with deprecation of previous core theme
- name: Verify core theme
run: |
pwd
yarn install --non-interactive
yarn verify
working-directory: ./CTFd/themes/core

82
.gitignore vendored Normal file
View File

@@ -0,0 +1,82 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
# C extensions
*.so
# Distribution / packaging
.Python
env/
venv*
.venv*
build/
develop-eggs/
dist/
downloads/
eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
# Translations
# TODO: CTFd 4.0 We should consider generating .mo files in a Docker image instead of saving them in git
# *.mo
# Django stuff:
*.log
# Sphinx documentation
docs/_build/
# PyBuilder
target/
.DS_Store
*.db
*.log
*.log.*
.idea/
.vscode/
CTFd/static/uploads
CTFd/uploads
.data/
.ctfd_secret_key
.*.swp
# Vagrant
.vagrant
# CTFd Exports
*.zip
# JS
node_modules/
# Flask Profiler files
flask_profiler.sql

7
.isort.cfg Normal file
View File

@@ -0,0 +1,7 @@
[settings]
multi_line_output=3
include_trailing_comma=True
force_grid_wrap=0
use_parentheses=True
line_length=88
skip=migrations

17
.prettierignore Normal file
View File

@@ -0,0 +1,17 @@
CTFd/themes/**/vendor/
CTFd/themes/core-deprecated/
CTFd/themes/core/static/
CTFd/themes/core-beta/**/*
CTFd/themes/admin/static/**/*
*.html
*.njk
*.png
*.svg
*.ico
*.ai
*.svg
*.mp3
*.webm
.pytest_cache
venv*
.venv*

2390
CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

21
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,21 @@
## 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.

354
CTFd/__init__.py Normal file
View File

@@ -0,0 +1,354 @@
import datetime
import os
import sys
import time
import weakref
from distutils.version import StrictVersion
import jinja2
from flask import Flask, Request
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 safe_join
from werkzeug.wsgi import get_host
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_cli,
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.8.1"
__channel__ = "oss"
class CTFdRequest(Request):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
"""
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
"""
self.path = self.script_root + 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
Flask.__init__(self, *args, **kwargs)
# Store server start time
self.start_time = datetime.datetime.utcnow()
# Create time-based run identifier.
# In production round the timestamp to incrase chances that workers get the same run_id
if self.debug:
time_based_run_id = str(self.start_time)
else:
time_based_run_id = str(round(self.start_time.timestamp() / 60) * 60)
self.time_based_run_id = sha256(time_based_run_id)[0:8]
@property
def run_id(self):
# use RUN_ID if exists, otherwise fall back to time_based_run_in
return self.config.get("RUN_ID") or self.time_based_run_id
def create_jinja_environment(self):
"""Overridden jinja environment constructor"""
return super(CTFdFlask, self).create_jinja_environment()
def create_url_adapter(self, request):
# TODO: Backport of TRUSTED_HOSTS behavior from Flask. Remove when possible.
# https://github.com/pallets/flask/pull/5637
if request is not None:
if (trusted_hosts := self.config.get("TRUSTED_HOSTS")) is not None:
request.trusted_hosts = trusted_hosts
# Check trusted_hosts here until bind_to_environ does.
request.host = get_host(request.environ, request.trusted_hosts)
return super(CTFdFlask, self).create_url_adapter(request)
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()
if "finalize" not in options:
# Suppress None into empty strings
options["finalize"] = lambda x: x if x is not None else ""
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 roughly copied from Jinja
# https://github.com/pallets/jinja/blob/b08cd4bc64bb980df86ed2876978ae5735572280/src/jinja2/environment.py#L956-L973
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
):
# template.globals is a ChainMap, modifying it will only
# affect the template, not the environment globals.
if globals:
template.globals.update(globals)
return template
template = self.loader.load(self, name, self.make_globals(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)
from CTFd.cache import cache
from CTFd.utils import import_in_progress
cache.init_app(app)
app.cache = cache
# If we are importing we should pause startup until the import is finished
while import_in_progress():
print("Import currently in progress, CTFd startup paused for 5 seconds")
time.sleep(5)
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__
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.share import social
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(social)
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)
init_cli(app)
return app

279
CTFd/admin/__init__.py Normal file
View File

@@ -0,0 +1,279 @@
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_all_team_sessions,
clear_all_user_sessions,
clear_challenges,
clear_config,
clear_pages,
clear_standings,
)
from CTFd.constants.setup import DEFAULTS
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_app_config, 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/<plugin>", 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 = "{}.{}.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,
max_age=-1,
download_name="{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}
# Load in defaults that would normally exist on UI setup
for k, v in DEFAULTS.items():
if k not in configs:
configs[k] = v
themes = ctf_config.get_themes()
# Remove current theme but ignore failure
try:
themes.remove(get_config("ctf_theme"))
except ValueError:
pass
force_html_sanitization = get_app_config("HTML_SANITIZATION")
return render_template(
"admin/config.html",
themes=themes,
**configs,
force_html_sanitization=force_html_sanitization
)
@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 data.get("user_mode") == "users":
db.session.query(Users).update({Users.team_id: None})
Teams.query.delete()
clear_all_user_sessions()
clear_all_team_sessions()
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")

120
CTFd/admin/challenges.py Normal file
View File

@@ -0,0 +1,120 @@
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.schemas.tags import TagSchema
from CTFd.utils.decorators import admins_only
from CTFd.utils.security.signing import serialize
from CTFd.utils.user import get_current_team, get_current_user
@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/<int:challenge_id>")
@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/preview/<int:challenge_id>")
@admins_only
def challenges_preview(challenge_id):
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
chal_class = get_chal_class(challenge.type)
user = get_current_user()
team = get_current_team()
files = []
for f in challenge.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)))
tags = [
tag["value"] for tag in TagSchema("user", many=True).dump(challenge.tags).data
]
content = render_template(
chal_class.templates["view"].lstrip("/"),
solves=None,
solved_by_me=False,
files=files,
tags=tags,
hints=challenge.hints,
max_attempts=challenge.max_attempts,
attempts=0,
challenge=challenge,
rating=None,
ratings=None,
)
return render_template(
"admin/challenges/preview.html", content=content, challenge=challenge
)
@admin.route("/admin/challenges/new")
@admins_only
def challenges_new():
types = CHALLENGE_CLASSES.keys()
return render_template("admin/challenges/new.html", types=types)

View File

@@ -0,0 +1,12 @@
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)

49
CTFd/admin/pages.py Normal file
View File

@@ -0,0 +1,49 @@
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/<int:page_id>")
@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)

16
CTFd/admin/scoreboard.py Normal file
View File

@@ -0,0 +1,16 @@
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
)

217
CTFd/admin/statistics.py Normal file
View File

@@ -0,0 +1,217 @@
from flask import render_template
from CTFd.admin import admin
from CTFd.models import Challenges, Fails, Solves, Teams, Tracking, Users, db
from CTFd.utils.config import get_config
from CTFd.utils.decorators import admins_only
from CTFd.utils.modes import get_model
from CTFd.utils.scores import get_standings
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)
account_scores = get_standings(count=100, admin=True)
# Get all challenges ordered by category and value
all_challenges = (
Challenges.query.filter(Challenges.state == "visible")
.order_by(Challenges.value.asc(), Challenges.category)
.all()
)
# Get solve matrix data for top 100 accounts
top_account_ids = [account.account_id for account in account_scores]
if top_account_ids:
solve_matrix_data = (
db.session.query(
Solves.account_id,
Solves.challenge_id,
Challenges.name.label("challenge_name"),
)
.join(Challenges, Challenges.id == Solves.challenge_id)
.join(Model, Model.id == Solves.account_id)
.filter(
Solves.account_id.in_(top_account_ids),
Model.banned == False,
Model.hidden == False,
Challenges.state == "visible",
)
.all()
)
# Get attempt matrix data (fails) for top 100 accounts
attempt_matrix_data = (
db.session.query(
Fails.account_id,
Fails.challenge_id,
Challenges.name.label("challenge_name"),
)
.join(Challenges, Challenges.id == Fails.challenge_id)
.join(Model, Model.id == Fails.account_id)
.filter(
Fails.account_id.in_(top_account_ids),
Model.banned == False,
Model.hidden == False,
Challenges.state == "visible",
)
.all()
)
# Get challenge opens matrix data for top 100 accounts
# Need to handle mapping from user_id (in Tracking) to account_id (user or team)
if get_config("user_mode") == "teams":
# In teams mode, map user_id to team_id (account_id)
opens_matrix_data = (
db.session.query(
Teams.id.label("account_id"),
Tracking.target.label("challenge_id"),
)
.join(Users, Users.id == Tracking.user_id)
.join(Teams, Teams.id == Users.team_id)
.join(Challenges, Challenges.id == Tracking.target)
.filter(
Teams.id.in_(top_account_ids),
Users.banned == False,
Users.hidden == False,
Teams.banned == False,
Teams.hidden == False,
Challenges.state == "visible",
Tracking.target.isnot(None), # Ensure target is not null
Tracking.type == "challenges.open", # Only track challenge opens
)
.distinct() # Remove duplicates if user opened same challenge multiple times
.all()
)
else:
# In users mode, user_id maps directly to account_id
opens_matrix_data = (
db.session.query(
Tracking.user_id.label("account_id"),
Tracking.target.label("challenge_id"),
)
.join(Users, Users.id == Tracking.user_id)
.join(Challenges, Challenges.id == Tracking.target)
.filter(
Tracking.user_id.in_(top_account_ids),
Users.banned == False,
Users.hidden == False,
Challenges.state == "visible",
Tracking.target.isnot(None), # Ensure target is not null
Tracking.type == "challenges.open", # Only track challenge opens
)
.distinct() # Remove duplicates if user opened same challenge multiple times
.all()
)
# Build matrix data structure
account_solves = {}
for account in account_scores:
account_solves[account.account_id] = {
"name": account.name,
"score": account.score,
"solved_challenges": set(),
"attempted_challenges": set(),
"opened_challenges": set(),
}
for solve in solve_matrix_data:
if solve.account_id in account_solves:
account_solves[solve.account_id]["solved_challenges"].add(
solve.challenge_id
)
for attempt in attempt_matrix_data:
if attempt.account_id in account_solves:
account_solves[attempt.account_id]["attempted_challenges"].add(
attempt.challenge_id
)
for opens in opens_matrix_data:
if opens.account_id in account_solves:
account_solves[opens.account_id]["opened_challenges"].add(
opens.challenge_id
)
else:
account_solves = {}
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,
top_users=account_scores,
all_challenges=all_challenges,
account_solves=account_solves,
)

65
CTFd/admin/submissions.py Normal file
View File

@@ -0,0 +1,65 @@
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/<submission_type>")
@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, error_out=False)
)
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,
)

86
CTFd/admin/teams.py Normal file
View File

@@ -0,0 +1,86 @@
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, error_out=False)
)
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/<int:team_id>")
@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,
)

109
CTFd/admin/users.py Normal file
View File

@@ -0,0 +1,109 @@
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, error_out=False)
)
else:
users = (
Users.query.filter(*filters)
.order_by(Users.id.asc())
.paginate(page=page, per_page=50, error_out=False)
)
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/<int:user_id>")
@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,
)

75
CTFd/api/__init__.py Normal file
View File

@@ -0,0 +1,75 @@
from flask import Blueprint, current_app
from flask_restx import Api
from CTFd.api.v1.awards import awards_namespace
from CTFd.api.v1.brackets import brackets_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.exports import exports_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.shares import shares_namespace
from CTFd.api.v1.solutions import solutions_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.",
},
},
security=["AccessToken"],
)
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")
CTFd_API_v1.add_namespace(shares_namespace, "/shares")
CTFd_API_v1.add_namespace(brackets_namespace, "/brackets")
CTFd_API_v1.add_namespace(exports_namespace, "/exports")
CTFd_API_v1.add_namespace(solutions_namespace, "/solutions")

0
CTFd/api/v1/__init__.py Normal file
View File

177
CTFd/api/v1/awards.py Normal file
View File

@@ -0,0 +1,177 @@
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("/<award_id>")
@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}

89
CTFd/api/v1/brackets.py Normal file
View File

@@ -0,0 +1,89 @@
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.request import validate_args
from CTFd.constants import RawEnum
from CTFd.models import Brackets, db
from CTFd.schemas.brackets import BracketSchema
from CTFd.utils.decorators import admins_only
from CTFd.utils.helpers.models import build_model_filters
brackets_namespace = Namespace("brackets", description="Endpoint to retrieve Brackets")
@brackets_namespace.route("")
class BracketList(Resource):
@validate_args(
{
"name": (str, None),
"description": (str, None),
"type": (str, None),
"q": (str, None),
"field": (
RawEnum(
"BracketFields",
{"name": "name", "description": "description", "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=Brackets, query=q, field=field)
brackets = Brackets.query.filter_by(**query_args).filter(*filters).all()
schema = BracketSchema(many=True)
response = schema.dump(brackets)
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 = BracketSchema()
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}
@brackets_namespace.route("/<int:bracket_id>")
class Bracket(Resource):
@admins_only
def patch(self, bracket_id):
bracket = Brackets.query.filter_by(id=bracket_id).first_or_404()
schema = BracketSchema()
req = request.get_json()
response = schema.load(req, session=db.session, instance=bracket)
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, bracket_id):
bracket = Brackets.query.filter_by(id=bracket_id).first_or_404()
db.session.delete(bracket)
db.session.commit()
db.session.close()
return {"success": True}

1275
CTFd/api/v1/challenges.py Normal file

File diff suppressed because it is too large Load Diff

159
CTFd/api/v1/comments.py Normal file
View File

@@ -0,0 +1,159 @@
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, error_out=False)
)
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("/<comment_id>")
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}

286
CTFd/api/v1/config.py Normal file
View File

@@ -0,0 +1,286 @@
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("/<config_key>")
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/<field_id>")
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}

46
CTFd/api/v1/exports.py Normal file
View File

@@ -0,0 +1,46 @@
from datetime import datetime as DateTime
from flask import request, send_file
from flask_restx import Namespace, Resource
from CTFd.utils.config import ctf_name
from CTFd.utils.csv import dump_csv
from CTFd.utils.decorators import admins_only, ratelimit
from CTFd.utils.exports import export_ctf as export_ctf_util
exports_namespace = Namespace("exports", description="Endpoint to retrieve Exports")
@exports_namespace.route("/raw")
class ExportList(Resource):
@admins_only
@ratelimit(method="POST", limit=10, interval=60)
def post(self):
req = request.get_json()
export_type = req.get("type", "_")
export_args = req.get("args", {})
day = DateTime.now().strftime("%Y-%m-%d_%T")
if export_type == "csv":
table = export_args.get("table")
if not table:
return {
"success": False,
"errors": {"args": "Missing table to export"},
}, 400
output = dump_csv(name=table)
return send_file(
output,
as_attachment=True,
max_age=-1,
download_name=f"{ctf_name()}-{table}-{day}.csv",
)
else:
backup = export_ctf_util()
full_name = f"{ctf_name()}.{day}.zip"
return send_file(
backup,
cache_timeout=-1,
as_attachment=True,
attachment_filename=full_name,
)

181
CTFd/api/v1/files.py Normal file
View File

@@ -0,0 +1,181 @@
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",
),
},
params={
"file": {
"in": "formData",
"type": "file",
"required": True,
"description": "The file to upload",
}
},
)
@validate_args(
{
"challenge_id": (int, None),
"challenge": (int, None),
"page_id": (int, None),
"page": (int, None),
"solution_id": (int, None),
"solution": (int, None),
"type": (str, None),
"location": (str, None),
},
location="form",
)
def post(self, form_args):
files = request.files.getlist("file")
location = form_args.get("location")
# challenge_id
# page_id
# Handle situation where users attempt to upload multiple files with a single location
if len(files) > 1 and location:
return {
"success": False,
"errors": {
"location": ["Location cannot be specified with multiple files"]
},
}, 400
objs = []
for f in files:
# uploads.upload_file(file=f, chalid=req.get('challenge'))
try:
obj = uploads.upload_file(file=f, **form_args)
except ValueError as e:
return {
"success": False,
"errors": {"location": [str(e)]},
}, 400
objs.append(obj)
schema = FileSchema(many=True)
response = schema.dump(objs)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
@files_namespace.route("/<file_id>")
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}

206
CTFd/api/v1/flags.py Normal file
View File

@@ -0,0 +1,206 @@
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
# We only want to operate on flag types where are have
# high confidence a leading/trailing space was not intentional
if response.data.type in ("static", "regex"):
response.data.content = response.data.content.strip()
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")
class FlagTypes(Resource):
@admins_only
def get(self):
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("/types/<type_name>")
class FlagType(Resource):
@admins_only
def get(self, type_name):
flag_class = get_flag_class(type_name)
response = {"name": flag_class.name, "templates": flag_class.templates}
return {"success": True, "data": response}
@flags_namespace.route("/<flag_id>")
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()
# We only want to operate on flag types where are have
# high confidence a leading/trailing space was not intentional
if flag.type in ("static", "regex") and req.get("content"):
req["content"] = req["content"].strip()
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}

View File

View File

@@ -0,0 +1,12 @@
# 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)

View File

@@ -0,0 +1,98 @@
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(),
"formData": 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()
defs = schema.pop("definitions", {})
props = schema.get("properties", {})
required = schema.get("required", [])
# Remove all titles and resolve all $refs in properties
for k in props:
if "title" in props[k]:
del props[k]["title"]
if "$ref" in props[k]:
definition: dict = defs[props[k].pop("$ref").split("/").pop()]
# Check if the schema is for enums, if so, we just add in the "enum" key
# else we add the whole schema into the properties
if "enum" in definition:
props[k]["enum"] = definition["enum"]
else:
props[k] = definition
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
nonlocal location
apidoc = getattr(func, "__apidoc__", {"params": {}})
if location == "form":
location = "formData"
if any(v["type"] == "file" for v in props.values()):
apidoc["consumes"] = ["multipart/form-data"]
else:
apidoc["consumes"] = [
"application/x-www-form-urlencoded",
"multipart/form-data",
]
if location == "json":
title = schema.get("title", "")
apidoc["consumes"] = ["application/json"]
apidoc["params"].update({title: {"in": "body", "schema": schema}})
else:
for k, v in props.items():
v["in"] = location
if k in required:
v["required"] = True
apidoc["params"][k] = v
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

View File

@@ -0,0 +1,35 @@
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

237
CTFd/api/v1/hints.py Normal file
View File

@@ -0,0 +1,237 @@
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 import get_config
from CTFd.utils.decorators import admins_only, during_ctf_time_only
from CTFd.utils.decorators.visibility import check_challenge_visibility
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("/<hint_id>")
class Hint(Resource):
@during_ctf_time_only
@check_challenge_visibility
@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):
hint = Hints.query.filter_by(id=hint_id).first_or_404()
user = get_current_user()
# We allow public accessing of hints if challenges are visible and there is no cost or prerequisites
# If there is a cost or a prereq we should block the user from seeing the hint
if user is None:
if hint.cost or hint.prerequisites:
return (
{
"success": False,
"errors": {"cost": ["You must login to unlock this hint"]},
},
403,
)
else:
# Allow admins to allow public CTFs to see free hints if they wish
if bool(get_config("hints_free_public_access", False)) is True:
response = HintSchema(view="unlocked").dump(hint)
return {"success": True, "data": response.data}
else:
return (
{
"success": False,
"errors": {"cost": ["You must login to unlock this hint"]},
},
403,
)
if hint.prerequisites:
requirements = hint.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}
# 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:
pass
else:
if is_admin() and request.args.get("preview", False):
pass
else:
return (
{
"success": False,
"errors": {
"requirements": [
"You must unlock other hints before accessing this hint"
]
},
},
403,
)
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}

View File

@@ -0,0 +1,189 @@
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("/<notification_id>")
@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}

177
CTFd/api/v1/pages.py Normal file
View File

@@ -0,0 +1,177 @@
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("/<page_id>")
@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}

View File

@@ -0,0 +1,105 @@
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]]

99
CTFd/api/v1/scoreboard.py Normal file
View File

@@ -0,0 +1,99 @@
from collections import defaultdict
from flask import request
from flask_restx import Namespace, Resource
from sqlalchemy import select
from CTFd.cache import cache, make_cache_key
from CTFd.models import Brackets, Users, db
from CTFd.utils import get_config
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.scoreboard import get_scoreboard_detail
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,
Users.bracket_id,
Brackets.name.label("bracket_name"),
]
)
.where(Users.team_id.isnot(None))
.join(Brackets, Users.bracket_id == Brackets.id, isouter=True)
)
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,
"bracket_id": u.bracket_id,
"bracket_name": u.bracket_name,
}
# 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),
"bracket_id": x.bracket_id,
"bracket_name": x.bracket_name,
}
if mode == TEAMS_MODE:
entry["members"] = list(membership[x.account_id].values())
response.append(entry)
return {"success": True, "data": response}
@scoreboard_namespace.route("/top/<int:count>")
@scoreboard_namespace.param("count", "How many top teams to return")
class ScoreboardDetail(Resource):
@check_account_visibility
@check_score_visibility
def get(self, count):
# Restrict count to some limit
count = max(1, min(count, 50))
bracket_id = request.args.get("bracket_id")
response = get_scoreboard_detail(count=count, bracket_id=bracket_id)
return {"success": True, "data": response}

25
CTFd/api/v1/shares.py Normal file
View File

@@ -0,0 +1,25 @@
from flask import abort, request
from flask_restx import Namespace, Resource
from CTFd.utils import get_config
from CTFd.utils.decorators import authed_only
from CTFd.utils.social import get_social_share
from CTFd.utils.user import get_current_user_attrs
shares_namespace = Namespace("shares", description="Endpoint to create Share links")
@shares_namespace.route("")
class Share(Resource):
@authed_only
def post(self):
if bool(get_config("social_shares", default=True)) is False:
abort(403)
req = request.get_json()
share_type = req.get("type")
SocialShare = get_social_share(type=share_type)
user = get_current_user_attrs()
share = SocialShare(user_id=user.id)
response = {"url": share.url}
return {"success": True, "data": response}

235
CTFd/api/v1/solutions.py Normal file
View File

@@ -0,0 +1,235 @@
from typing import List
from flask import abort, 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 Solutions, SolutionUnlocks, db
from CTFd.schemas.solutions import SolutionSchema
from CTFd.utils.challenges import get_solve_ids_for_user_id
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, is_admin
solutions_namespace = Namespace(
"solutions", description="Endpoint to retrieve and create Solutions"
)
SolutionModel = sqlalchemy_to_pydantic(Solutions)
TransientSolutionModel = sqlalchemy_to_pydantic(Solutions, exclude=["id"])
class SolutionDetailedSuccessResponse(APIDetailedSuccessResponse):
data: SolutionModel
class SolutionListSuccessResponse(APIListSuccessResponse):
data: List[SolutionModel]
solutions_namespace.schema_model(
"SolutionDetailedSuccessResponse", SolutionDetailedSuccessResponse.apidoc()
)
solutions_namespace.schema_model(
"SolutionListSuccessResponse", SolutionListSuccessResponse.apidoc()
)
@solutions_namespace.route("")
class SolutionList(Resource):
@admins_only
@solutions_namespace.doc(
description="Endpoint to get solution objects in bulk",
responses={
200: ("Success", "SolutionListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
@validate_args(
{
"state": (str, None),
"q": (str, None),
"field": (
RawEnum(
"SolutionFields",
{
"state": "state",
},
),
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=Solutions, query=q, field=field)
args = query_args
schema = SolutionSchema(many=True)
solutions = Solutions.query.filter_by(**args).filter(*filters).all()
response = schema.dump(solutions)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {
"success": True,
"data": response.data,
}
@admins_only
@solutions_namespace.doc(
description="Endpoint to create a solution object",
responses={
200: ("Success", "SolutionDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
403: (
"Access denied",
"APISimpleErrorResponse",
),
},
)
@validate_args(
TransientSolutionModel,
location="json",
)
def post(self, json_args):
req = json_args
# Set default state if not provided
if "state" not in req or req["state"] is None:
req["state"] = "hidden"
solution = Solutions(**req)
schema = SolutionSchema()
db.session.add(solution)
db.session.commit()
response = schema.dump(solution)
db.session.close()
return {"success": True, "data": response.data}
@solutions_namespace.route("/<solution_id>")
@solutions_namespace.param("solution_id", "A Solution ID")
class Solution(Resource):
@during_ctf_time_only
@require_verified_emails
@authed_only
@solutions_namespace.doc(
description="Endpoint to get a solution object",
responses={
200: ("Success", "SolutionDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self, solution_id):
solution = Solutions.query.filter_by(id=solution_id).first_or_404()
user = get_current_user()
if is_admin():
view = "admin"
else:
if solution.state == "hidden":
abort(404)
if solution.challenge.state == "hidden":
abort(404)
# If the solution state is visible we just let it through
if solution.state == "visible":
pass
elif solution.state == "solved":
user_solves = get_solve_ids_for_user_id(user_id=user.id)
if solution.challenge.id not in user_solves:
abort(404)
else:
# Different behavior can be implemented in a plugin for the route
abort(404)
view = "locked"
unlocked = SolutionUnlocks.query.filter_by(
account_id=user.account_id, target=solution.id
).first()
if unlocked:
view = "unlocked"
schema = SolutionSchema(view=view)
response = schema.dump(solution)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
@admins_only
@solutions_namespace.doc(
description="Endpoint to edit a solution object",
responses={
200: ("Success", "SolutionDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def patch(self, solution_id):
solution = Solutions.query.filter_by(id=solution_id).first_or_404()
req = request.get_json()
schema = SolutionSchema(partial=True)
response = schema.load(req, instance=solution, partial=True)
if response.errors:
return {"success": False, "errors": response.errors}, 400
db.session.commit()
response = schema.dump(solution)
db.session.close()
return {"success": True, "data": response.data}
@admins_only
@solutions_namespace.doc(
description="Endpoint to delete a solution object",
responses={
200: ("Success", "APISimpleSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def delete(self, solution_id):
solution = Solutions.query.filter_by(id=solution_id).first_or_404()
db.session.delete(solution)
db.session.commit()
return {"success": True}

View File

@@ -0,0 +1,12 @@
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

View File

@@ -0,0 +1,137 @@
from flask import request
from flask_restx import Resource
from sqlalchemy import Integer, func
from sqlalchemy.sql import and_
from sqlalchemy.sql.expression import cast
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/<column>")
class ChallengePropertyCounts(Resource):
@admins_only
def get(self, column):
# TODO: Probably rename this function in CTFd 4.0 as it can be used to do more than just counts now
funcs = {
"count": func.count,
"sum": func.sum,
}
aggregate_func = funcs[request.args.get("function", "count")]
if column in Challenges.__table__.columns.keys():
c1 = getattr(Challenges, column)
c2 = getattr(
Challenges, request.args.get("target", "category"), Challenges.category
)
# We cast this to Integer to deal with cases where SQLAlchemy will give us a Decimal instead
data = (
Challenges.query.with_entities(c1, cast(aggregate_func(c2), Integer))
.group_by(c1)
.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(
Challenges.id,
Challenges.name,
Challenges.state,
Challenges.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}

View File

@@ -0,0 +1,43 @@
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}}

View File

@@ -0,0 +1,23 @@
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/<column>")
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

View File

@@ -0,0 +1,14 @@
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}

View File

@@ -0,0 +1,30 @@
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/<column>")
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

260
CTFd/api/v1/submissions.py Normal file
View File

@@ -0,0 +1,260 @@
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,
PaginatedAPIListSuccessResponse,
)
from CTFd.cache import cache, clear_challenges, clear_standings
from CTFd.constants import RawEnum
from CTFd.models import Solves, 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, error_out=False)
)
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("/<submission_id>")
@submissions_namespace.param("submission_id", "A Submission ID")
class Submission(Resource):
@admins_only
@submissions_namespace.doc(
description="Endpoint to get a submission object",
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 edit a submission object",
responses={
200: ("Success", "SubmissionDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def patch(self, submission_id):
submission = Submissions.query.filter_by(id=submission_id).first_or_404()
req = request.get_json()
submission_type = req.get("type")
if submission_type == "correct":
existing_solve = Solves.query.filter_by(
challenge_id=submission.challenge_id,
user_id=submission.user_id,
team_id=submission.team_id if submission.team_id else None,
).first()
# If a solve for this user / team pair exists, don't create a new solve
if existing_solve:
return {
"success": False,
"errors": {"type": ["Solve already exists for this submission"]},
}, 400
solve = Solves(
user_id=submission.user_id,
challenge_id=submission.challenge_id,
team_id=submission.team_id,
ip=submission.ip,
provided=submission.provided,
date=submission.date,
)
db.session.add(solve)
submission.type = "discard"
db.session.commit()
# Delete standings cache
clear_standings()
clear_challenges()
submission = solve
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 delete a submission object",
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()
account_id = submission.account_id
challenge_id = submission.challenge_id
db.session.delete(submission)
db.session.commit()
db.session.close()
# Clear out the user's recent attempt count
# This is a little lossy but I don't think it matters in practice
acc_kpm_key = f"account_kpm_{account_id}_{challenge_id}"
cache.expire(acc_kpm_key, 0)
# Delete standings cache
clear_standings()
clear_challenges()
return {"success": True}

166
CTFd/api/v1/tags.py Normal file
View File

@@ -0,0 +1,166 @@
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("/<tag_id>")
@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}

669
CTFd/api/v1/teams.py Normal file
View File

@@ -0,0 +1,669 @@
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",
"email": "email",
},
),
None,
),
},
location="query",
)
def get(self, query_args):
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
if field == "email":
if is_admin() is False:
return {
"success": False,
"errors": {"field": "Emails can only be queried by admins"},
}, 400
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, error_out=False)
)
else:
teams = (
Teams.query.filter_by(hidden=False, banned=False, **query_args)
.filter(*filters)
.paginate(per_page=50, max_per_page=100, error_out=False)
)
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("/<int:team_id>")
@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
# A team can always calculate their score regardless of any setting because they can simply sum all of their challenges
# Therefore a team requesting their private data should be able to get their own current score
# However place is not something that a team can ascertain on their own so it is always gated behind freeze time
response.data["place"] = team.place
response.data["score"] = team.get_score(admin=True)
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("/<team_id>/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("/<team_id>/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("/<team_id>/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("/<team_id>/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}}

155
CTFd/api/v1/tokens.py Normal file
View File

@@ -0,0 +1,155 @@
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")
description = req.get("description")
if expiration:
expiration = datetime.datetime.strptime(expiration, "%Y-%m-%d")
user = get_current_user()
token = generate_user_token(
user, expiration=expiration, description=description
)
# 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("/<token_id>")
@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}

181
CTFd/api/v1/topics.py Normal file
View File

@@ -0,0 +1,181 @@
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("/<topic_id>")
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}

205
CTFd/api/v1/unlocks.py Normal file
View File

@@ -0,0 +1,205 @@
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()
target_type = req["type"]
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()
if target_type == "hints":
# We should use the team's score if in teams mode
# user.account gives the appropriate account based on team mode
# Use get_score with admin to get the account's full score value
if target.cost > user.account.get_score(admin=True):
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 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}
elif target_type == "solutions":
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 target"},
},
400,
)
db.session.add(response.data)
db.session.commit()
response = schema.dump(response.data)
return {"success": True, "data": response.data}
else:
return (
{
"success": False,
"errors": {"type": "Unknown target type"},
},
400,
)

537
CTFd/api/v1/users.py Normal file
View File

@@ -0,0 +1,537 @@
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.challenges import get_submissions_for_user_id_for_challenge_id
from CTFd.utils.config import get_config, 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",
"email": "email",
},
),
None,
),
},
location="query",
)
def get(self, query_args):
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
if field == "email":
if is_admin() is False:
return {
"success": False,
"errors": {"field": "Emails can only be queried by admins"},
}, 400
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, error_out=False)
)
else:
users = (
Users.query.filter_by(banned=False, hidden=False, **query_args)
.filter(*filters)
.paginate(per_page=50, max_per_page=100, error_out=False)
)
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("/<int:user_id>")
@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
# A user can always calculate their score regardless of any setting because they can simply sum all of their challenges
# Therefore a user requesting their private data should be able to get their own current score
# However place is not something that a user can ascertain on their own so it is always gated behind freeze time
response["place"] = user.place
response["score"] = user.get_score(admin=True)
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/submissions")
class UserPrivateSubmissions(Resource):
@authed_only
def get(self):
# TODO: CTFd 4.0 Self viewing submissions should not be enabled by default until further notice
if bool(get_config("view_self_submissions")) is False:
abort(403)
user = get_current_user()
challenge_id = request.args.get("challenge_id")
response = get_submissions_for_user_id_for_challenge_id(
user_id=user.id, challenge_id=challenge_id
)
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/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("/<user_id>/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("/<user_id>/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("/<user_id>/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("/<int:user_id>/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,
)

682
CTFd/auth.py Normal file
View File

@@ -0,0 +1,682 @@
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 flask_babel import lazy_gettext as _l
from CTFd.cache import cache, clear_team_session, clear_user_session
from CTFd.exceptions.email import (
UserConfirmTokenInvalidException,
UserResetPasswordTokenInvalidException,
)
from CTFd.models import Brackets, 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 can_send_mail, 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 generate_preset_admin, login_user, logout_user
from CTFd.utils.security.email import (
remove_email_confirm_token,
remove_reset_password_token,
verify_email_confirm_token,
verify_reset_password_token,
)
from CTFd.utils.validators import ValidationError
auth = Blueprint("auth", __name__)
@auth.route("/confirm", methods=["POST", "GET"])
@auth.route("/confirm/<data>", methods=["POST", "GET"])
@ratelimit(method="POST", limit=10, interval=60)
def confirm(data=None):
# If we can't send mails our behavior depends on verify_emails
if not can_send_mail():
if get_config("verify_emails") is False:
return redirect(url_for("challenges.listing"))
else:
return render_template(
"confirm.html",
errors=[
"Email verification is enabled but email sending isn't available. Please contact an admin to confirm your account"
],
)
# User is confirming email account
if data and request.method == "GET":
try:
user_email = verify_email_confirm_token(data)
except (UserConfirmTokenInvalidException):
return render_template(
"confirm.html",
errors=["Your confirmation link is invalid, please generate a new one"],
)
user = Users.query.filter_by(email=user_email).first_or_404()
if user.verified:
return redirect(url_for("views.settings"))
if (
get_app_config("EMAIL_CONFIRMATION_REQUIRE_INTERACTION")
and request.args.get("interaction") is None
):
button = """<button style="margin-top: 3rem; padding: 1rem;" onclick="
let u = new window.URL(window.location.href);
u.searchParams.set('interaction', '1');
window.location.href = u;">Click Here to Confirm Email</button>"""
return render_template("page.html", content=button)
user.verified = True
log(
"registrations",
format="[{date}] {ip} - successful confirmation for {name}",
name=user.name,
)
db.session.commit()
remove_email_confirm_token(data)
clear_user_session(user_id=user.id)
# Only send this registration notification if we are preventing access to registered users only
if get_config("verify_emails"):
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/<data>", methods=["POST", "GET"])
@ratelimit(method="POST", limit=10, interval=60)
def reset_password(data=None):
if config.can_send_mail() is False and data is None:
return render_template(
"reset_password.html",
errors=[
markup(
"This CTF is not configured to send email.<br> Please contact an organizer to have your password reset."
)
],
)
if data is not None:
try:
email_address = verify_reset_password_token(data)
except (UserResetPasswordTokenInvalidException):
return render_template(
"reset_password.html",
errors=["Your reset link is invalid, please generate a new one"],
)
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=[_l("Please pick a longer password")]
)
password_min_length = int(get_config("password_min_length", default=0))
pass_min = len(password) < password_min_length
if pass_min:
return render_template(
"reset_password.html",
errors=[
_l(
f"Password must be at least {password_min_length} characters"
)
],
)
user.password = password
user.change_password = False
db.session.commit()
remove_reset_password_token(data)
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=[
_l(
"If that account exists you will receive an email, please check your inbox"
)
],
)
if user.oauth_id:
return render_template(
"reset_password.html",
infos=[
_l(
"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."
)
],
)
# Preferably this would be in a pipeline or multi but the benefit is minor
limit = cache.inc(f"reset_password_attempt_user_{user.id}")
cache.expire(f"reset_password_attempt_user_{user.id}", 180)
if limit > 5:
return render_template(
"reset_password.html",
errors=[
_l("Too many password reset attempts. Please try again later.")
],
)
email.forgot_password(email_address)
return render_template(
"reset_password.html",
infos=[
_l(
"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"))
num_users_limit = int(get_config("num_users", default=0))
num_users = Users.query.filter_by(banned=False, hidden=False).count()
if num_users_limit and num_users >= num_users_limit:
abort(
403,
description=f"Reached the maximum number of users ({num_users_limit}).",
)
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", ""))
bracket_id = request.form.get("bracket_id", None)
name_len = len(name) == 0
names = (
Users.query.add_columns(Users.name, Users.id).filter_by(name=name).first()
)
emails = (
Users.query.add_columns(Users.email, Users.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)
password_min_length = int(get_config("password_min_length", default=0))
pass_min = len(password) < password_min_length
if get_config("registration_code"):
if (
registration_code.lower()
!= str(get_config("registration_code", default="")).lower()
):
errors.append(_l("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(_l("Please provide all required fields"))
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 bracket_id:
valid_bracket = bool(
Brackets.query.filter_by(id=bracket_id, type="users").first()
)
else:
if Brackets.query.filter_by(type="users").count():
valid_bracket = False
else:
valid_bracket = True
if not valid_email:
errors.append(_l("Please enter a valid email address"))
if email.check_email_is_whitelisted(email_address) is False:
errors.append(_l("Your email address is not from an allowed domain"))
if email.check_email_is_blacklisted(email_address) is True:
errors.append(_l("Your email address is not from an allowed domain"))
if names:
errors.append(_l("That user name is already taken"))
if team_name_email_check is True:
errors.append(_l("Your user name cannot be an email address"))
if emails:
errors.append(_l("That email has already been used"))
if pass_short:
errors.append(_l("Pick a longer password"))
if password_min_length and pass_min:
errors.append(
_l(f"Password must be at least {password_min_length} characters")
)
if pass_long:
errors.append(_l("Pick a shorter password"))
if name_len:
errors.append(_l("Pick a longer user name"))
if valid_website is False:
errors.append(
_l("Websites must be a proper URL starting with http or https")
)
if valid_country is False:
errors.append(_l("Invalid country"))
if valid_affiliation is False:
errors.append(_l("Please provide a shorter affiliation"))
if valid_bracket is False:
errors.append(_l("Please provide a valid bracket"))
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,
bracket_id=bracket_id,
)
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 for preset admin credentials first
preset_admin_name = get_app_config("PRESET_ADMIN_NAME")
preset_admin_email = get_app_config("PRESET_ADMIN_EMAIL")
preset_admin_password = get_app_config("PRESET_ADMIN_PASSWORD")
if preset_admin_name and preset_admin_email and preset_admin_password:
password = request.form.get("password", "")
# Check if credentials match preset admin
if (
name == preset_admin_name or name == preset_admin_email
) and password == preset_admin_password:
admin = generate_preset_admin()
if admin:
login_user(user=admin)
return redirect(url_for("challenges.listing"))
else:
errors.append(
"Preset admin user could not be created. Please contact an administrator"
)
return render_template("login.html", errors=errors)
# 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, timeout=5)
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, timeout=5).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:
# Respect the user count limit
num_users_limit = int(get_config("num_users", default=0))
num_users = Users.query.filter_by(banned=False, hidden=False).count()
if num_users_limit and num_users >= num_users_limit:
abort(
403,
description=f"Reached the maximum number of users ({num_users_limit}).",
)
# 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"))

253
CTFd/cache/__init__.py vendored Normal file
View File

@@ -0,0 +1,253 @@
from functools import lru_cache, wraps
from hashlib import md5
from time import monotonic_ns
from flask import current_app, request
from flask_caching import Cache, make_template_fragment_key
class CTFdCache(Cache):
"""
This subclass exists to give flask-caching some additional features
Ideally likely we should have our own isolated redis connection but that might introduce more issues
"""
def inc(self, *args, **kwargs):
"""
Support redis INCR in flask-caching
Note that redis INCR does not expire by default
https://github.com/pallets-eco/flask-caching/issues/418
"""
inc = getattr(self.cache, "inc", None)
if inc is not None and callable(inc):
return inc(*args, **kwargs)
raise NotImplementedError
def expire(self, key, timeout):
"""
Support redis EXPIRE in flask-caching
"""
if current_app.config["CACHE_TYPE"] == "redis":
return self.cache._write_client.expire(
f"{self.cache.key_prefix}{key}", timeout
)
else:
# Generic alternative that leverages flask-caching built-ins to do expiration
if timeout <= 0:
self.cache.delete(key)
value = self.get(key)
if value:
self.set(key=key, value=value, timeout=timeout)
return True
return False
cache = CTFdCache()
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 make_cache_key_with_query_string(allowed_params=None, query_string_hash=None):
if allowed_params is None:
allowed_params = []
def _make_cache_key_with_query_string(path=None, key_prefix="view/%s/%s"):
if path is None:
path = request.endpoint
if query_string_hash:
args_hash = query_string_hash
else:
args_hash = calculate_param_hash(
params=tuple(request.args.items(multi=True)),
allowed_params=allowed_params,
)
cache_key = key_prefix % (path, args_hash)
return cache_key
return _make_cache_key_with_query_string
def calculate_param_hash(params, allowed_params=None):
# Copied from Flask-Caching but modified to allow only accepted parameters
if allowed_params:
args_as_sorted_tuple = tuple(
sorted(pair for pair in params if pair[0] in allowed_params)
)
else:
args_as_sorted_tuple = tuple(sorted(pair for pair in params))
args_hash = md5(str(args_as_sorted_tuple).encode()).hexdigest() # nosec B303 B324
return args_hash
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.api import api
from CTFd.api.v1.scoreboard import ScoreboardDetail, ScoreboardList
from CTFd.constants.static import CacheKeys
from CTFd.models import Teams, Users # noqa: I001
from CTFd.utils.scoreboard import get_scoreboard_detail
from CTFd.utils.scores import get_standings, get_team_standings, get_user_standings
from CTFd.utils.user import (
get_team_place,
get_team_score,
get_user_place,
get_user_score,
)
# Clear out the bulk standings functions
cache.delete_memoized(get_standings)
cache.delete_memoized(get_team_standings)
cache.delete_memoized(get_user_standings)
cache.delete_memoized(get_scoreboard_detail)
# 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)
cache.delete_memoized(ScoreboardDetail.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_rating_average_for_challenge_id,
get_solve_counts_for_challenges,
get_solve_ids_for_user_id,
get_solves_for_challenge_id,
get_submissions_for_user_id_for_challenge_id,
)
cache.delete_memoized(get_all_challenges)
cache.delete_memoized(get_solves_for_challenge_id)
cache.delete_memoized(get_submissions_for_user_id_for_challenge_id)
cache.delete_memoized(get_solve_ids_for_user_id)
cache.delete_memoized(get_solve_counts_for_challenges)
cache.delete_memoized(get_rating_average_for_challenge_id)
def clear_ratings():
from CTFd.utils.challenges import get_rating_average_for_challenge_id
cache.delete_memoized(get_rating_average_for_challenge_id)
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_recent_ips,
get_user_score,
)
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_recent_ips,
get_user_score,
)
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)

49
CTFd/challenges.py Normal file
View File

@@ -0,0 +1,49 @@
from flask import Blueprint, redirect, render_template, request, url_for
from flask_babel import lazy_gettext as _l
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(_l("Challenge Visibility is set to Admins Only"))
if ctf_started() is False:
errors.append(_l("%(ctf_name)s has not started yet", ctf_name=Configs.ctf_name))
if ctf_paused() is True:
infos.append(_l("%(ctf_name)s is paused", ctf_name=Configs.ctf_name))
if ctf_ended() is True:
infos.append(_l("%(ctf_name)s has ended", ctf_name=Configs.ctf_name))
return render_template("challenges.html", infos=infos, errors=errors)

92
CTFd/cli/__init__.py Normal file
View File

@@ -0,0 +1,92 @@
import datetime
import shutil
from pathlib import Path
import click
from flask import Blueprint, current_app
from CTFd.utils import get_config as get_config_util
from CTFd.utils import set_config as set_config_util
from CTFd.utils.config import ctf_name
from CTFd.utils.exports import export_ctf as export_ctf_util
from CTFd.utils.exports import import_ctf as import_ctf_util
from CTFd.utils.exports import set_import_end_time, set_import_error
_cli = Blueprint("cli", __name__)
def jsenums():
import json
import os
from CTFd.constants import JS_ENUMS
path = os.path.join(current_app.root_path, "themes/core/assets/js/constants.js")
with open(path, "w+") as f:
for k, v in JS_ENUMS.items():
f.write("const {} = Object.freeze({});".format(k, json.dumps(v)))
BUILD_COMMANDS = {"jsenums": jsenums}
@_cli.cli.command("get_config")
@click.argument("key")
def get_config(key):
print(get_config_util(key))
@_cli.cli.command("set_config")
@click.argument("key")
@click.argument("value")
def set_config(key, value):
print(set_config_util(key, value).value)
@_cli.cli.command("build")
@click.argument("cmd")
def build(cmd):
cmd = BUILD_COMMANDS.get(cmd)
cmd()
@_cli.cli.command("export_ctf")
@click.argument("path", default="")
def export_ctf(path):
backup = export_ctf_util()
if path:
with open(path, "wb") as target:
shutil.copyfileobj(backup, target)
else:
name = ctf_name()
day = datetime.datetime.now().strftime("%Y-%m-%d_%T")
full_name = f"{name}.{day}.zip"
with open(full_name, "wb") as target:
shutil.copyfileobj(backup, target)
print(f"Exported {full_name}")
@_cli.cli.command("import_ctf")
@click.argument("path", type=click.Path(exists=True))
@click.option(
"--delete_import_on_finish",
default=False,
is_flag=True,
help="Delete import file when import is finished",
)
def import_ctf(path, delete_import_on_finish=False):
try:
import_ctf_util(path)
except Exception as e:
from CTFd.utils.dates import unix_time
set_import_error("Import Failure: " + str(e))
set_import_end_time(value=unix_time(datetime.datetime.utcnow()))
if delete_import_on_finish:
print(f"Deleting {path}")
Path(path).unlink()

361
CTFd/config.ini Normal file
View File

@@ -0,0 +1,361 @@
# 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:<YOUR_PASSWORD_HERE>@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]
# TRUSTED_HOSTS
# Comma seperated string containing valid host names for CTFd to respond to. If not specified,
# CTFd will respond to requests for any host unless otherwise restricted in an upstream proxy.
# Each value is either an exact match, or can start with a dot . to match any subdomain.
#
# It is recommended that most users set this to the server name that they expect to be using
#
# Example: example.com,ctfd.io,.ctfd.io
# See https://flask.palletsprojects.com/en/stable/config/#TRUSTED_HOSTS
TRUSTED_HOSTS =
# 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
# CROSS_ORIGIN_OPENER_POLICY
# Setting for the Cross-Origin-Opener-Policy response header. Defaults to same-origin-allow-popups
CROSS_ORIGIN_OPENER_POLICY =
[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 =
# AWS_S3_ADDRESSING_STYLE
# The S3 addressing style to use for URLs. Only used under the s3 uploader.
# Defaults to auto; can be set to virtual or path.
# See https://boto3.amazonaws.com/v1/documentation/api/latest/guide/configuration.html
AWS_S3_ADDRESSING_STYLE =
# AWS_S3_CUSTOM_DOMAIN
# A hostname that replaces the default hostname in the generated S3 download URLs. Required by some S3 providers or CDNs.
# Only used under the s3 uploader.
AWS_S3_CUSTOM_DOMAIN =
# AWS_S3_CUSTOM_PREFIX
# Add a prefix to the file path to be placed in S3.
# Only used under the s3 uploader.
AWS_S3_CUSTOM_PREFIX =
[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 =
# RUN_ID
# Specifies an identifier that can be used to cache bust between deployments
# If not set, CTFd will default to an time-based identifier
RUN_ID =
# 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 =
# EMAIL_CONFIRMATION_REQUIRE_INTERACTION
# If enabled email confirmation links will show a page with a button for email confirmation instead of confirming immediately upon link open
# Can help with properly confirming emails under certain link phishing protections
# Defaults to false
EMAIL_CONFIRMATION_REQUIRE_INTERACTION =
# EXTRA_CONFIGS_FORCE_TYPES =
# Specifies config keys that will always be treated as a specific type
# Can help with situations where CTFd's extra config processing is resulting in errors
# Supported types are str, int, float, bool
# Provide as CONFIG1=type,CONFIG2=type,CONFIG3=type
EXTRA_CONFIGS_FORCE_TYPES =
# 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 =
[management]
# PRESET_ADMIN_NAME
# A preset admin will be created by CTFd when the credentials provided below are used to login.
# Preset admins are only created. An existing user with the same credentials are not promoted to admins.
# This setting specifies the username for login
PRESET_ADMIN_NAME =
# PRESET_ADMIN_EMAIL
# This setting specifies the email address for the preset admin credentials
PRESET_ADMIN_EMAIL =
# PRESET_ADMIN_PASSWORD
# This setting specifies the password for the preset admin credentials
PRESET_ADMIN_PASSWORD =
# PRESET_ADMIN_TOKEN
# This setting specifies an optional API token that will be assigned to the preset admin user.
# If the preset admin token is provided to the API, CTFd will create the associated admin user as if it was a standard login attempt
PRESET_ADMIN_TOKEN =
# PRESET_CONFIGS
# Configuration values that will be loaded into CTFd and take precedence over database settings.
# This setting should be a JSON object containing the configuration keys and values to pre-configure CTFd instances
# To reiterate, a configuration specified via preset cannot be overriden by an admin in the web ui
# An example is provided below
# PRESET_CONFIGS = {
# "setup": true,
# "ctf_name": "ExampleCTF"
# }
PRESET_CONFIGS =
[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

331
CTFd/config.py Normal file
View File

@@ -0,0 +1,331 @@
import configparser
import json
import os
from distutils.util import strtobool
from typing import Union
from sqlalchemy.engine.url import URL
_FORCED_EXTRA_CONFIG_TYPES = {}
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, key=option)
else:
return value
def process_string_var(value, key=None):
if key in _FORCED_EXTRA_CONFIG_TYPES:
t = _FORCED_EXTRA_CONFIG_TYPES[key]
if t == "str":
return str(value)
elif t == "int":
return int(value)
elif t == "float":
return float(value)
elif t == "bool":
return bool(strtobool(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
CROSS_ORIGIN_OPENER_POLICY: str = empty_str_cast(config_ini["security"].get("CROSS_ORIGIN_OPENER_POLICY")) \
or "same-origin-allow-popups"
TRUSTED_HOSTS: list[str] | None = None
if config_ini["security"].get("TRUSTED_HOSTS"):
TRUSTED_HOSTS = [
h.strip() for h in empty_str_cast(config_ini["security"].get("TRUSTED_HOSTS")).split(",")
]
"""
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"])
AWS_S3_CUSTOM_DOMAIN: str = empty_str_cast(config_ini["uploads"].get("AWS_S3_CUSTOM_DOMAIN", ""))
AWS_S3_CUSTOM_PREFIX: str = empty_str_cast(config_ini["uploads"].get("AWS_S3_CUSTOM_PREFIX", ""))
AWS_S3_ADDRESSING_STYLE: str = empty_str_cast(config_ini["uploads"].get("AWS_S3_ADDRESSING_STYLE", ""), default="auto")
# === 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="/")
RUN_ID: str = empty_str_cast(config_ini["optional"].get("RUN_ID"), default=None)
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))
EMAIL_CONFIRMATION_REQUIRE_INTERACTION: bool = process_boolean_str(empty_str_cast(config_ini["optional"].get("EMAIL_CONFIRMATION_REQUIRE_INTERACTION", False), default=False))
EXTRA_CONFIGS_FORCE_TYPES: str = empty_str_cast(config_ini["optional"].get("EXTRA_CONFIGS_FORCE_TYPES"), default=None)
if EXTRA_CONFIGS_FORCE_TYPES:
config_types = EXTRA_CONFIGS_FORCE_TYPES.split(",")
for entry in config_types:
k, v = entry.split("=")
_FORCED_EXTRA_CONFIG_TYPES[k] = v
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"])
# === MANAGEMENT ===
PRESET_ADMIN_NAME: str = empty_str_cast(config_ini["management"].get("PRESET_ADMIN_NAME", "")) if config_ini.has_section("management") else None
PRESET_ADMIN_EMAIL: str = empty_str_cast(config_ini["management"].get("PRESET_ADMIN_EMAIL", "")) if config_ini.has_section("management") else None
PRESET_ADMIN_PASSWORD: str = empty_str_cast(config_ini["management"].get("PRESET_ADMIN_PASSWORD", "")) if config_ini.has_section("management") else None
PRESET_ADMIN_TOKEN: str = empty_str_cast(config_ini["management"].get("PRESET_ADMIN_TOKEN", "")) if config_ini.has_section("management") else None
PRESET_CONFIGS: str = empty_str_cast(config_ini["management"].get("PRESET_CONFIGS", "")) if config_ini.has_section("management") else None
if PRESET_CONFIGS and SAFE_MODE is False:
try:
PRESET_CONFIGS = json.loads(PRESET_CONFIGS)
except (ValueError, TypeError):
print("Exception occurred during PRESET_CONFIGS loading")
PRESET_CONFIGS = {}
else:
PRESET_CONFIGS = {}
# === EXTRA ===
# Since the configurations in section "[extra]" will be loaded later, it is not necessary to declare them here.
# However, if you want to have some processing or checking on the value, you can still declare it here just like other configurations.
# 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"):
# We should only add the values that are not yet loaded in ServerConfig.
if hasattr(Config, k):
raise ValueError(
f"Built-in Config {k} should not be defined in the [extra] section of config.ini"
)
setattr(Config, k, v)

View File

@@ -0,0 +1,63 @@
from enum import Enum
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 JINJA_ENUMS:
JINJA_ENUMS[cls.__name__] = cls
else:
raise KeyError("{} was already defined as a JinjaEnum".format(cls.__name__))
return cls

79
CTFd/constants/assets.py Normal file
View File

@@ -0,0 +1,79 @@
import os
from flask import current_app, url_for
from CTFd.constants.themes import DEFAULT_THEME
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=None, _return_none_on_load_failure=False):
if theme is None:
theme = ctf_theme()
file_path = os.path.join(
current_app.root_path, "themes", theme, "static", "manifest.json"
)
if bool(current_app.config.get("THEME_FALLBACK")) and not os.path.isfile(
file_path
):
theme = DEFAULT_THEME
file_path = os.path.join(
current_app.root_path, "themes", theme, "static", "manifest.json"
)
try:
manifest = get_asset_json(path=file_path)
except FileNotFoundError as e:
# This check allows us to determine if we are on a legacy theme and fallback if necessary
if _return_none_on_load_failure:
manifest = None
else:
raise e
return manifest
def js(self, asset_key, theme=None, type="module", defer=False, extra=""):
if theme is None:
theme = ctf_theme()
asset = self.manifest(theme=theme)[asset_key]
entry = asset["file"]
imports = asset.get("imports", [])
# Add in extra attributes. Note that type="module" imples defer
_attrs = ""
if type:
_attrs = f'type="{type}" '
if defer:
_attrs += "defer "
if extra:
_attrs += extra
html = ""
for i in imports:
# TODO: Needs a better recursive solution
i = self.manifest(theme=theme)[i]["file"]
url = url_for("views.themes_beta", theme=theme, path=i)
html += f'<script {_attrs} src="{url}"></script>'
url = url_for("views.themes_beta", theme=theme, path=entry)
html += f'<script {_attrs} src="{url}"></script>'
return markup(html)
def css(self, asset_key, theme=None):
if theme is None:
theme = ctf_theme()
asset = self.manifest(theme=theme)[asset_key]
entry = asset["file"]
url = url_for("views.themes_beta", theme=theme, path=entry)
return markup(f'<link rel="stylesheet" href="{url}">')
def file(self, asset_key, theme=None):
if theme is None:
theme = ctf_theme()
asset = self.manifest(theme=theme)[asset_key]
entry = asset["file"]
return url_for("views.themes_beta", theme=theme, path=entry)
Assets = _AssetsWrapper()

74
CTFd/constants/config.py Normal file
View File

@@ -0,0 +1,74 @@
import json
from flask import url_for
# TODO: CTFd 4.0. These imports previously specified in this file but have moved. We could consider removing these imports
from CTFd.constants.options import ( # noqa: F401
AccountVisibilityTypes,
ChallengeVisibilityTypes,
ConfigTypes,
RegistrationVisibilityTypes,
ScoreVisibilityTypes,
UserModeTypes,
)
from CTFd.utils import get_config
class _ConfigsWrapper:
def __getattr__(self, attr):
return get_config(attr)
@property
def ctf_name(self):
return get_config("ctf_name", default="CTFd")
@property
def ctf_small_icon(self):
icon = get_config("ctf_small_icon")
if icon:
return url_for("views.files", path=icon)
return url_for("views.themes", path="img/favicon.ico")
@property
def theme_header(self):
from CTFd.utils.helpers import markup
return markup(get_config("theme_header", default=""))
@property
def theme_footer(self):
from CTFd.utils.helpers import markup
return markup(get_config("theme_footer", default=""))
@property
def theme_settings(self):
try:
return json.loads(get_config("theme_settings", default="null"))
except json.JSONDecodeError:
return {"error": "invalid theme_settings"}
@property
def tos_or_privacy(self):
tos = bool(get_config("tos_url") or get_config("tos_text"))
privacy = bool(get_config("privacy_url") or get_config("privacy_text"))
return tos or privacy
@property
def tos_link(self):
return get_config("tos_url", default=url_for("views.tos"))
@property
def privacy_link(self):
return get_config("privacy_url", default=url_for("views.privacy"))
@property
def social_shares(self):
return get_config("social_shares", default=True)
@property
def challenge_ratings(self):
return get_config("challenge_ratings", default="public")
Configs = _ConfigsWrapper()

31
CTFd/constants/email.py Normal file
View File

@@ -0,0 +1,31 @@
DEFAULT_VERIFICATION_EMAIL_SUBJECT = "Confirm your account for {ctf_name}"
DEFAULT_VERIFICATION_EMAIL_BODY = (
"Welcome to {ctf_name}!\n\n"
"Click the following link to confirm and activate your account:\n"
"{url}"
"\n\n"
"If the link is not clickable, try copying and pasting it into your browser."
)
DEFAULT_SUCCESSFUL_REGISTRATION_EMAIL_SUBJECT = "Successfully registered for {ctf_name}"
DEFAULT_SUCCESSFUL_REGISTRATION_EMAIL_BODY = (
"You've successfully registered for {ctf_name}!"
)
DEFAULT_USER_CREATION_EMAIL_SUBJECT = "Message from {ctf_name}"
DEFAULT_USER_CREATION_EMAIL_BODY = (
"A new account has been created for you for {ctf_name} at {url}. \n\n"
"Username: {name}\n"
"Password: {password}"
)
DEFAULT_PASSWORD_RESET_SUBJECT = "Password Reset Request from {ctf_name}"
DEFAULT_PASSWORD_RESET_BODY = (
"Did you initiate a password reset on {ctf_name}? "
"If you didn't initiate this request you can ignore this email. \n\n"
"Click the following link to reset your password:\n{url}\n\n"
"If the link is not clickable, try copying and pasting it into your browser."
)
DEFAULT_PASSWORD_CHANGE_ALERT_SUBJECT = "Password Change Confirmation for {ctf_name}"
DEFAULT_PASSWORD_CHANGE_ALERT_BODY = (
"Your password for {ctf_name} has been changed.\n\n"
"If you didn't request a password change you can reset your password here:\n{url}\n\n"
"If the link is not clickable, try copying and pasting it into your browser."
)

View File

@@ -0,0 +1,61 @@
from CTFd.constants import RawEnum
class Languages(str, RawEnum):
ENGLISH = "en"
GERMAN = "de"
POLISH = "pl"
SPANISH = "es"
ARABIC = "ar"
CHINESE = "zh_CN"
TAIWANESE = "zh_TW"
FRENCH = "fr"
KOREAN = "ko"
RUSSIAN = "ru"
BRAZILIAN_PORTUGESE = "pt_BR"
SLOVAK = "sk"
JAPANESE = "ja"
ITALIAN = "it"
VIETNAMESE = "vi"
CATALAN = "ca"
GREEK = "el"
FINNISH = "fi"
ROMANIAN = "ro"
SLOVENIAN = "sl"
SWEDISH = "sv"
HEBREW = "he"
UZBEK = "uz"
LANGUAGE_NAMES = {
"en": "English",
"de": "Deutsch",
"pl": "Polski",
"es": "Español",
"ar": "اَلْعَرَبِيَّةُ",
"zh_CN": "简体中文",
"zh_TW": "繁體中文",
"fr": "Français",
"ko": "한국어",
"ru": "русский язык",
"pt_BR": "Português do Brasil",
"sk": "Slovenský jazyk",
"ja": "日本語",
"it": "Italiano",
"vi": "tiếng Việt",
"ca": "Català",
"el": "Ελληνικά",
"fi": "Suomi",
"ro": "Română",
"sl": "Slovenščina",
"sv": "Svenska",
"he": "עברית",
"uz": "oʻzbekcha",
}
SELECT_LANGUAGE_LIST = [("", "")] + [
(str(lang), LANGUAGE_NAMES.get(str(lang))) for lang in Languages
]
Languages.names = LANGUAGE_NAMES
Languages.select_list = SELECT_LANGUAGE_LIST

43
CTFd/constants/options.py Normal file
View File

@@ -0,0 +1,43 @@
from CTFd.constants import JinjaEnum, RawEnum
class ConfigTypes(str, RawEnum):
CHALLENGE_VISIBILITY = "challenge_visibility"
SCORE_VISIBILITY = "score_visibility"
ACCOUNT_VISIBILITY = "account_visibility"
REGISTRATION_VISIBILITY = "registration_visibility"
@JinjaEnum
class UserModeTypes(str, RawEnum):
USERS = "users"
TEAMS = "teams"
@JinjaEnum
class ChallengeVisibilityTypes(str, RawEnum):
PUBLIC = "public"
PRIVATE = "private"
ADMINS = "admins"
@JinjaEnum
class ScoreVisibilityTypes(str, RawEnum):
PUBLIC = "public"
PRIVATE = "private"
HIDDEN = "hidden"
ADMINS = "admins"
@JinjaEnum
class AccountVisibilityTypes(str, RawEnum):
PUBLIC = "public"
PRIVATE = "private"
ADMINS = "admins"
@JinjaEnum
class RegistrationVisibilityTypes(str, RawEnum):
PUBLIC = "public"
PRIVATE = "private"
MLC = "mlc"

54
CTFd/constants/plugins.py Normal file
View File

@@ -0,0 +1,54 @@
from flask import current_app
from CTFd.plugins import get_admin_plugin_menu_bar, get_user_page_menu_bar
from CTFd.utils.helpers import markup
from CTFd.utils.plugins import get_registered_scripts, get_registered_stylesheets
class _PluginWrapper:
@property
def scripts(self):
application_root = current_app.config.get("APPLICATION_ROOT")
subdir = application_root != "/"
scripts = []
for script in get_registered_scripts():
if script.startswith("http"):
scripts.append(f'<script defer src="{script}"></script>')
elif subdir:
scripts.append(
f'<script defer src="{application_root}/{script}"></script>'
)
else:
scripts.append(f'<script defer src="{script}"></script>')
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'<link rel="stylesheet" type="text/css" href="{stylesheet}">'
)
elif subdir:
_styles.append(
f'<link rel="stylesheet" type="text/css" href="{application_root}/{stylesheet}">'
)
else:
_styles.append(
f'<link rel="stylesheet" type="text/css" href="{stylesheet}">'
)
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()

View File

@@ -0,0 +1,18 @@
from flask import session
class _SessionWrapper:
@property
def id(self):
return session.get("id", 0)
@property
def nonce(self):
return session.get("nonce")
@property
def hash(self):
return session.get("hash")
Session = _SessionWrapper()

21
CTFd/constants/setup.py Normal file
View File

@@ -0,0 +1,21 @@
from CTFd.constants.options import (
AccountVisibilityTypes,
ChallengeVisibilityTypes,
RegistrationVisibilityTypes,
ScoreVisibilityTypes,
UserModeTypes,
)
from CTFd.constants.themes import DEFAULT_THEME
DEFAULTS = {
# General Settings
"ctf_name": "CTFd",
"user_mode": UserModeTypes.USERS,
# Visual/Style Settings
"ctf_theme": DEFAULT_THEME,
# Visibility Settings
"challenge_visibility": ChallengeVisibilityTypes.PRIVATE,
"registration_visibility": RegistrationVisibilityTypes.PUBLIC,
"score_visibility": ScoreVisibilityTypes.PUBLIC,
"account_visibility": AccountVisibilityTypes.PUBLIC,
}

14
CTFd/constants/static.py Normal file
View File

@@ -0,0 +1,14 @@
from CTFd.constants import JinjaEnum, RawEnum
@JinjaEnum
class CacheKeys(str, RawEnum):
PUBLIC_SCOREBOARD_TABLE = "public_scoreboard_table"
# Placeholder object. Not used, just imported to force initialization of any Enums here
class _StaticsWrapper:
pass
Static = _StaticsWrapper()

46
CTFd/constants/teams.py Normal file
View File

@@ -0,0 +1,46 @@
from collections import namedtuple
# TODO: CTFd 4.0. Consider changing to a dataclass
TeamAttrsFields = [
"id",
"oauth_id",
"name",
"email",
"secret",
"website",
"affiliation",
"country",
"bracket_id",
"hidden",
"banned",
"captain_id",
"created",
]
TeamAttrs = namedtuple(
"TeamAttrs",
TeamAttrsFields,
defaults=(None,) * len(TeamAttrsFields),
)
class _TeamAttrsWrapper:
def __getattr__(self, attr):
from CTFd.utils.user import get_current_team_attrs
attrs = get_current_team_attrs()
return getattr(attrs, attr, None)
@property
def place(self):
from CTFd.utils.user import get_team_place
return get_team_place(team_id=self.id)
@property
def score(self):
from CTFd.utils.user import get_team_score
return get_team_score(team_id=self.id)
Team = _TeamAttrsWrapper()

2
CTFd/constants/themes.py Normal file
View File

@@ -0,0 +1,2 @@
ADMIN_THEME = "admin"
DEFAULT_THEME = "core"

48
CTFd/constants/users.py Normal file
View File

@@ -0,0 +1,48 @@
from collections import namedtuple
# TODO: CTFd 4.0. Consider changing to a dataclass
UserAttrsFields = [
"id",
"oauth_id",
"name",
"email",
"type",
"secret",
"website",
"affiliation",
"country",
"bracket_id",
"hidden",
"banned",
"verified",
"language",
"team_id",
"created",
"change_password",
]
UserAttrs = namedtuple(
"UserAttrs", UserAttrsFields, defaults=(None,) * len(UserAttrsFields)
)
class _UserAttrsWrapper:
def __getattr__(self, attr):
from CTFd.utils.user import get_current_user_attrs
attrs = get_current_user_attrs()
return getattr(attrs, attr, None)
@property
def place(self):
from CTFd.utils.user import get_user_place
return get_user_place(user_id=self.id)
@property
def score(self):
from CTFd.utils.user import get_user_score
return get_user_score(user_id=self.id)
User = _UserAttrsWrapper()

21
CTFd/errors.py Normal file
View File

@@ -0,0 +1,21 @@
import jinja2.exceptions
from flask import render_template
from werkzeug.exceptions import InternalServerError
def render_error(error):
if (
isinstance(error, InternalServerError)
and error.description == InternalServerError.description
):
error.description = "An Internal Server Error has occurred"
try:
return (
render_template(
"errors/{}.html".format(error.code),
error=error.description,
),
error.code,
)
except jinja2.exceptions.TemplateNotFound:
return error.get_response()

26
CTFd/events/__init__.py Normal file
View File

@@ -0,0 +1,26 @@
from flask import Blueprint, Response, current_app, stream_with_context
from CTFd.models import db
from CTFd.utils import get_app_config
from CTFd.utils.decorators import authed_only, ratelimit
events = Blueprint("events", __name__)
@events.route("/events")
@authed_only
@ratelimit(method="GET", limit=150, interval=60)
def subscribe():
@stream_with_context
def gen():
for event in current_app.events_manager.subscribe():
yield str(event)
enabled = get_app_config("SERVER_SENT_EVENTS")
if enabled is False:
return ("", 204)
# Close the db session to avoid OperationalError with MySQL connection errors
db.session.close()
return Response(gen(), mimetype="text/event-stream")

View File

@@ -0,0 +1,14 @@
class UserNotFoundException(Exception):
pass
class UserTokenExpiredException(Exception):
pass
class TeamTokenExpiredException(Exception):
pass
class TeamTokenInvalidException(Exception):
pass

View File

@@ -0,0 +1,10 @@
class ChallengeCreateException(Exception):
pass
class ChallengeUpdateException(Exception):
pass
class ChallengeSolveException(Exception):
pass

6
CTFd/exceptions/email.py Normal file
View File

@@ -0,0 +1,6 @@
class UserConfirmTokenInvalidException(Exception):
pass
class UserResetPasswordTokenInvalidException(Exception):
pass

88
CTFd/fonts/OFL.txt Normal file
View File

@@ -0,0 +1,88 @@
Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans)
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

51
CTFd/forms/__init__.py Normal file
View File

@@ -0,0 +1,51 @@
from wtforms import Form
from wtforms.csrf.core import CSRF
class CTFdCSRF(CSRF):
def generate_csrf_token(self, csrf_token_field):
from flask import session
return session.get("nonce")
class BaseForm(Form):
class Meta:
csrf = True
csrf_class = CTFdCSRF
csrf_field_name = "nonce"
class _FormsWrapper:
pass
Forms = _FormsWrapper()
from CTFd.forms import auth # noqa: I001 isort:skip
from CTFd.forms import self # noqa: I001 isort:skip
from CTFd.forms import teams # noqa: I001 isort:skip
from CTFd.forms import setup # noqa: I001 isort:skip
from CTFd.forms import submissions # noqa: I001 isort:skip
from CTFd.forms import users # noqa: I001 isort:skip
from CTFd.forms import challenges # noqa: I001 isort:skip
from CTFd.forms import language # noqa: I001 isort:skip
from CTFd.forms import notifications # noqa: I001 isort:skip
from CTFd.forms import config # noqa: I001 isort:skip
from CTFd.forms import pages # noqa: I001 isort:skip
from CTFd.forms import awards # noqa: I001 isort:skip
from CTFd.forms import email # noqa: I001 isort:skip
Forms.auth = auth
Forms.self = self
Forms.teams = teams
Forms.setup = setup
Forms.submissions = submissions
Forms.users = users
Forms.challenges = challenges
Forms.language = language
Forms.notifications = notifications
Forms.config = config
Forms.pages = pages
Forms.awards = awards
Forms.email = email

88
CTFd/forms/auth.py Normal file
View File

@@ -0,0 +1,88 @@
from flask_babel import lazy_gettext as _l
from wtforms import PasswordField, StringField
from wtforms.fields.html5 import EmailField
from wtforms.validators import InputRequired
from CTFd.forms import BaseForm
from CTFd.forms.fields import SubmitField
from CTFd.forms.users import (
attach_custom_user_fields,
attach_registration_code_field,
attach_user_bracket_field,
build_custom_user_fields,
build_registration_code_field,
build_user_bracket_field,
)
from CTFd.utils import get_config
def RegistrationForm(*args, **kwargs):
password_min_length = int(get_config("password_min_length", default=0))
password_description = _l("Password used to log into your account")
if password_min_length:
password_description += _l(
f" (Must be at least {password_min_length} characters)"
)
class _RegistrationForm(BaseForm):
name = StringField(
_l("User Name"),
description="Your username on the site",
validators=[InputRequired()],
render_kw={"autofocus": True},
)
email = EmailField(
_l("Email"),
description="Never shown to the public",
validators=[InputRequired()],
)
password = PasswordField(
_l("Password"),
description=password_description,
validators=[InputRequired()],
)
submit = SubmitField(_l("Submit"))
@property
def extra(self):
return (
build_custom_user_fields(
self, include_entries=False, blacklisted_items=()
)
+ build_registration_code_field(self)
+ build_user_bracket_field(self)
)
attach_custom_user_fields(_RegistrationForm)
attach_registration_code_field(_RegistrationForm)
attach_user_bracket_field(_RegistrationForm)
return _RegistrationForm(*args, **kwargs)
class LoginForm(BaseForm):
name = StringField(
_l("User Name or Email"),
validators=[InputRequired()],
render_kw={"autofocus": True},
)
password = PasswordField(_l("Password"), validators=[InputRequired()])
submit = SubmitField(_l("Submit"))
class ConfirmForm(BaseForm):
submit = SubmitField(_l("Send Confirmation Email"))
class ResetPasswordRequestForm(BaseForm):
email = EmailField(
_l("Email"), validators=[InputRequired()], render_kw={"autofocus": True}
)
submit = SubmitField(_l("Submit"))
class ResetPasswordForm(BaseForm):
password = PasswordField(
_l("Password"), validators=[InputRequired()], render_kw={"autofocus": True}
)
submit = SubmitField(_l("Submit"))

30
CTFd/forms/awards.py Normal file
View File

@@ -0,0 +1,30 @@
from wtforms import RadioField, StringField, TextAreaField
from wtforms.fields.html5 import IntegerField
from CTFd.forms import BaseForm
from CTFd.forms.fields import SubmitField
class AwardCreationForm(BaseForm):
name = StringField("Name")
value = IntegerField("Value")
category = StringField("Category")
description = TextAreaField("Description")
submit = SubmitField("Create")
icon = RadioField(
"Icon",
choices=[
("", "None"),
("shield", "Shield"),
("bug", "Bug"),
("crown", "Crown"),
("crosshairs", "Crosshairs"),
("ban", "Ban"),
("lightning", "Lightning"),
("skull", "Skull"),
("brain", "Brain"),
("code", "Code"),
("cowboy", "Cowboy"),
("angry", "Angry"),
],
)

30
CTFd/forms/challenges.py Normal file
View File

@@ -0,0 +1,30 @@
from wtforms import MultipleFileField, SelectField, StringField
from wtforms.validators import InputRequired
from CTFd.forms import BaseForm
from CTFd.forms.fields import SubmitField
class ChallengeSearchForm(BaseForm):
field = SelectField(
"Search Field",
choices=[
("name", "Name"),
("id", "ID"),
("category", "Category"),
("type", "Type"),
],
default="name",
validators=[InputRequired()],
)
q = StringField("Parameter", validators=[InputRequired()])
submit = SubmitField("Search")
class ChallengeFilesUploadForm(BaseForm):
file = MultipleFileField(
"Upload Files",
description="Attach multiple files using Control+Click or Cmd+Click.",
validators=[InputRequired()],
)
submit = SubmitField("Upload")

344
CTFd/forms/config.py Normal file
View File

@@ -0,0 +1,344 @@
from wtforms import BooleanField, FileField, SelectField, StringField, TextAreaField
from wtforms.fields.html5 import IntegerField, URLField
from wtforms.widgets.html5 import NumberInput
from CTFd.constants.config import (
AccountVisibilityTypes,
ChallengeVisibilityTypes,
RegistrationVisibilityTypes,
ScoreVisibilityTypes,
)
from CTFd.constants.email import (
DEFAULT_PASSWORD_CHANGE_ALERT_BODY,
DEFAULT_PASSWORD_CHANGE_ALERT_SUBJECT,
DEFAULT_PASSWORD_RESET_BODY,
DEFAULT_PASSWORD_RESET_SUBJECT,
DEFAULT_SUCCESSFUL_REGISTRATION_EMAIL_BODY,
DEFAULT_SUCCESSFUL_REGISTRATION_EMAIL_SUBJECT,
DEFAULT_USER_CREATION_EMAIL_BODY,
DEFAULT_USER_CREATION_EMAIL_SUBJECT,
DEFAULT_VERIFICATION_EMAIL_BODY,
DEFAULT_VERIFICATION_EMAIL_SUBJECT,
)
from CTFd.constants.languages import SELECT_LANGUAGE_LIST
from CTFd.forms import BaseForm
from CTFd.forms.fields import SubmitField
from CTFd.utils.csv import get_dumpable_tables
from CTFd.utils.social import BASE_TEMPLATE
class ResetInstanceForm(BaseForm):
accounts = BooleanField(
"Accounts",
description="Deletes all user and team accounts and their associated information",
)
submissions = BooleanField(
"Submissions",
description="Deletes all records that accounts gained points or took an action",
)
challenges = BooleanField(
"Challenges", description="Deletes all challenges and associated data"
)
pages = BooleanField(
"Pages", description="Deletes all pages and their associated files"
)
notifications = BooleanField(
"Notifications", description="Deletes all notifications"
)
submit = SubmitField("Reset CTF")
class AccountSettingsForm(BaseForm):
domain_whitelist = StringField(
"Email Domain Allowlist",
description="Comma-seperated list of allowable email domains which users can register under (e.g. examplectf.com, example.com, *.example.com)",
)
domain_blacklist = StringField(
"Email Domain Blocklist",
description="Comma-seperated list of disallowed email domains which users cannot register under (e.g. examplectf.com, example.com, *.example.com)",
)
team_creation = SelectField(
"Team Creation",
description="Control whether users can create their own teams (Teams mode only)",
choices=[("true", "Enabled"), ("false", "Disabled")],
default="true",
)
team_size = IntegerField(
widget=NumberInput(min=0),
description="Maximum number of users per team (Teams mode only)",
)
num_teams = IntegerField(
"Maximum Number of Teams",
widget=NumberInput(min=0),
description="Maximum number of teams allowed to register with this CTF (Teams mode only)",
)
num_users = IntegerField(
"Maximum Number of Users",
widget=NumberInput(min=0),
description="Maximum number of user accounts allowed to register with this CTF",
)
password_min_length = IntegerField(
"Minimum Password Length for Users",
widget=NumberInput(min=0),
description="Minimum Password Length for Users",
)
verify_emails = SelectField(
"Verify Emails",
description="Control whether users must confirm their email addresses before playing",
choices=[("true", "Enabled"), ("false", "Disabled")],
default="false",
)
team_disbanding = SelectField(
"Team Disbanding",
description="Control whether team captains are allowed to disband their own teams",
choices=[
("inactive_only", "Enabled for Inactive Teams"),
("disabled", "Disabled"),
],
default="inactive_only",
)
name_changes = SelectField(
"Name Changes",
description="Control whether users and teams can change their names",
choices=[("true", "Enabled"), ("false", "Disabled")],
default="true",
)
incorrect_submissions_per_min = IntegerField(
"Incorrect Submissions per Minute",
widget=NumberInput(min=1),
description="Number of submissions allowed per minute for flag bruteforce protection (default: 10)",
)
submit = SubmitField("Update")
class ExportCSVForm(BaseForm):
table = SelectField("Database Table", choices=get_dumpable_tables())
submit = SubmitField("Download CSV")
class ImportCSVForm(BaseForm):
csv_type = SelectField(
"CSV Type",
choices=[("users", "Users"), ("teams", "Teams"), ("challenges", "Challenges")],
description="Type of CSV data",
)
csv_file = FileField("CSV File", description="CSV file contents")
class SocialSettingsForm(BaseForm):
social_shares = SelectField(
"Social Shares",
description="Enable or Disable social sharing links for challenge solves",
choices=[("true", "Enabled"), ("false", "Disabled")],
default="true",
)
social_share_solve_template = TextAreaField(
"Social Share Solve Template",
description="HTML for Share Template",
default=BASE_TEMPLATE,
)
submit = SubmitField("Update")
class LegalSettingsForm(BaseForm):
tos_url = URLField(
"Terms of Service URL",
description="External URL to a Terms of Service document hosted elsewhere",
)
tos_text = TextAreaField(
"Terms of Service",
description="Text shown on the Terms of Service page",
)
privacy_url = URLField(
"Privacy Policy URL",
description="External URL to a Privacy Policy document hosted elsewhere",
)
privacy_text = TextAreaField(
"Privacy Policy",
description="Text shown on the Privacy Policy page",
)
submit = SubmitField("Update")
class ChallengeSettingsForm(BaseForm):
view_self_submissions = SelectField(
"View Self Submissions",
description="Allow users to view their previous submissions",
choices=[("true", "Enabled"), ("false", "Disabled")],
default="false",
)
max_attempts_behavior = SelectField(
"Max Attempts Behavior",
description="Set Max Attempts behavior to be a lockout or a timeout",
choices=[("lockout", "lockout"), ("timeout", "timeout")],
default="lockout",
)
max_attempts_timeout = IntegerField(
"Max Attempts Timeout Duration",
description="How long the timeout lasts in seconds for max attempts (if set to timeout). Default is 300 seconds",
default=300,
)
hints_free_public_access = SelectField(
"Hints Free Public Access",
description="Control whether users must be logged in to see free hints (hints without cost or requirements)",
choices=[("true", "Enabled"), ("false", "Disabled")],
default="false",
)
challenge_ratings = SelectField(
"Challenge Ratings",
description="Control who can see and submit challenge ratings",
choices=[
("public", "Public (users can submit ratings and see aggregated ratings)"),
(
"private",
"Private (users can submit ratings but cannot see aggregated ratings)",
),
(
"disabled",
"Disabled (users cannot submit ratings or see aggregated ratings)",
),
],
default="public",
)
class VisibilitySettingsForm(BaseForm):
challenge_visibility = SelectField(
"Challenge Visibility",
description="Control whether users must be logged in to see challenges",
choices=[
(ChallengeVisibilityTypes.PUBLIC, "Public"),
(ChallengeVisibilityTypes.PRIVATE, "Private"),
(ChallengeVisibilityTypes.ADMINS, "Admins Only"),
],
default=ChallengeVisibilityTypes.PRIVATE,
)
account_visibility = SelectField(
"Account Visibility",
description="Control whether accounts (users & teams) are shown to everyone, only to authenticated users, or only to admins",
choices=[
(AccountVisibilityTypes.PUBLIC, "Public"),
(AccountVisibilityTypes.PRIVATE, "Private"),
(AccountVisibilityTypes.ADMINS, "Admins Only"),
],
default=AccountVisibilityTypes.PUBLIC,
)
score_visibility = SelectField(
"Score Visibility",
description="Control whether solves/score are shown to the public, to logged in users, hidden to all non-admins, or only shown to admins",
choices=[
(ScoreVisibilityTypes.PUBLIC, "Public"),
(ScoreVisibilityTypes.PRIVATE, "Private"),
(ScoreVisibilityTypes.HIDDEN, "Hidden"),
(ScoreVisibilityTypes.ADMINS, "Admins Only"),
],
default=ScoreVisibilityTypes.PUBLIC,
)
registration_visibility = SelectField(
"Registration Visibility",
description="Control whether registration is enabled for everyone or disabled",
choices=[
(RegistrationVisibilityTypes.PUBLIC, "Public"),
(RegistrationVisibilityTypes.PRIVATE, "Private"),
(RegistrationVisibilityTypes.MLC, "MajorLeagueCyber Only"),
],
default=RegistrationVisibilityTypes.PUBLIC,
)
class LocalizationForm(BaseForm):
default_locale = SelectField(
"Default Language",
description="Language to use if a user does not specify a language in their account settings. By default, CTFd will auto-detect the user's preferred language.",
choices=SELECT_LANGUAGE_LIST,
)
class EmailSettingsForm(BaseForm):
# Mail Server Settings
mailfrom_addr = StringField(
"Mail From Address", description="Email address used to send email"
)
mail_server = StringField(
"Mail Server Address",
description="Change the email server used by CTFd to send email",
)
mail_port = IntegerField(
"Mail Server Port",
widget=NumberInput(min=1, max=65535),
description="Mail Server Port",
)
mail_useauth = BooleanField("Use Mail Server Username and Password")
mail_username = StringField("Username", description="Mail Server Account Username")
mail_password = StringField("Password", description="Mail Server Account Password")
mail_ssl = BooleanField("TLS/SSL")
mail_tls = BooleanField("STARTTLS")
# Mailgun Settings (deprecated)
mailgun_base_url = StringField(
"Mailgun API Base URL", description="Mailgun API Base URL"
)
mailgun_api_key = StringField("Mailgun API Key", description="Mailgun API Key")
# Registration Email
successful_registration_email_subject = StringField(
"Subject",
description="Subject line for registration confirmation email",
default=DEFAULT_SUCCESSFUL_REGISTRATION_EMAIL_SUBJECT,
)
successful_registration_email_body = TextAreaField(
"Body",
description="Email body sent to users after they've finished registering",
default=DEFAULT_SUCCESSFUL_REGISTRATION_EMAIL_BODY,
)
# Verification Email
verification_email_subject = StringField(
"Subject",
description="Subject line for account verification email",
default=DEFAULT_VERIFICATION_EMAIL_SUBJECT,
)
verification_email_body = TextAreaField(
"Body",
description="Email body sent to users to confirm their account at the address they provided during registration",
default=DEFAULT_VERIFICATION_EMAIL_BODY,
)
# Account Details Email
user_creation_email_subject = StringField(
"Subject",
description="Subject line for new account details email",
default=DEFAULT_USER_CREATION_EMAIL_SUBJECT,
)
user_creation_email_body = TextAreaField(
"Body",
description="Email body sent to new users (manually created by an admin) with their initial account details",
default=DEFAULT_USER_CREATION_EMAIL_BODY,
)
# Password Reset Email
password_reset_subject = StringField(
"Subject",
description="Subject line for password reset request email",
default=DEFAULT_PASSWORD_RESET_SUBJECT,
)
password_reset_body = TextAreaField(
"Body",
description="Email body sent when a user requests a password reset",
default=DEFAULT_PASSWORD_RESET_BODY,
)
# Password Reset Confirmation Email
password_change_alert_subject = StringField(
"Subject",
description="Subject line for password reset confirmation email",
default=DEFAULT_PASSWORD_CHANGE_ALERT_SUBJECT,
)
password_change_alert_body = TextAreaField(
"Body",
description="Email body sent when a user successfully resets their password",
default=DEFAULT_PASSWORD_CHANGE_ALERT_BODY,
)
submit = SubmitField("Update")

10
CTFd/forms/email.py Normal file
View File

@@ -0,0 +1,10 @@
from wtforms import TextAreaField
from wtforms.validators import InputRequired
from CTFd.forms import BaseForm
from CTFd.forms.fields import SubmitField
class SendEmailForm(BaseForm):
text = TextAreaField("Message", validators=[InputRequired()])
submit = SubmitField("Send")

17
CTFd/forms/fields.py Normal file
View File

@@ -0,0 +1,17 @@
from wtforms import SubmitField as _SubmitField
class SubmitField(_SubmitField):
"""
This custom SubmitField exists because wtforms is dumb.
See https://github.com/wtforms/wtforms/issues/205, https://github.com/wtforms/wtforms/issues/36
The .submit() handler in JS will break if the form has an input with the name or id of "submit" so submit fields need to be changed.
"""
def __init__(self, *args, **kwargs):
name = kwargs.pop("name", "_submit")
super().__init__(*args, **kwargs)
if self.name == "submit" or name:
self.id = name
self.name = name

19
CTFd/forms/language.py Normal file
View File

@@ -0,0 +1,19 @@
from wtforms import RadioField
from CTFd.constants.languages import LANGUAGE_NAMES
from CTFd.forms import BaseForm
def LanguageForm(*args, **kwargs):
from CTFd.utils.user import get_locale
class _LanguageForm(BaseForm):
"""Language form for only switching langauge without rendering all profile settings"""
language = RadioField(
"",
choices=LANGUAGE_NAMES.items(),
default=get_locale(),
)
return _LanguageForm(*args, **kwargs)

View File

@@ -0,0 +1,26 @@
from wtforms import BooleanField, RadioField, StringField, TextAreaField
from wtforms.validators import InputRequired
from CTFd.forms import BaseForm
from CTFd.forms.fields import SubmitField
class NotificationForm(BaseForm):
title = StringField("Title", description="Notification title")
content = TextAreaField(
"Content",
description="Notification contents. Can consist of HTML and/or Markdown.",
)
type = RadioField(
"Notification Type",
choices=[("toast", "Toast"), ("alert", "Alert"), ("background", "Background")],
default="toast",
description="What type of notification users receive",
validators=[InputRequired()],
)
sound = BooleanField(
"Play Sound",
default=True,
description="Play sound for users when they receive the notification",
)
submit = SubmitField("Submit")

Some files were not shown because too many files have changed in this diff Show More