Squashed 'CTFd/themes/core-beta/' changes from 9126d77d..5ce3003b

5ce3003b Merge pull request #47 from aCursedComrade/patch-1
c9887cb1 Fix team template

git-subtree-dir: CTFd/themes/core-beta
git-subtree-split: 5ce3003b4d68352e629ee2d390bc999e7d6b071e
This commit is contained in:
Kevin Chung
2023-06-11 15:56:28 -04:00
parent 692c4b086c
commit a64e7d51ef
815 changed files with 683 additions and 101218 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,19 +0,0 @@
<!--
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**

View File

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

View File

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

View File

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

View File

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

View File

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

97
.gitignore vendored
View File

@@ -1,81 +1,24 @@
# Byte-compiled / optimized / DLL files # Logs
__pycache__/ logs
*.py[cod]
# C extensions
*.so
# Distribution / packaging
.Python
env/
venv*
.venv*
build/
develop-eggs/
dist/
downloads/
eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
# Translations
*.mo
# Django stuff:
*.log *.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Sphinx documentation node_modules
docs/_build/ dist
dist-ssr
# PyBuilder *.local
target/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store .DS_Store
*.suo
*.db *.ntvs*
*.log *.njsproj
*.log.* *.sln
.idea/ *.sw?
.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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +0,0 @@
## How to contribute to CTFd
#### **Did you find a bug?**
- **Do not open up a GitHub issue if the bug is a security vulnerability in CTFd**. Instead [email the details to us at support@ctfd.io](mailto:support@ctfd.io).
- **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/CTFd/CTFd/issues).
- If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/CTFd/CTFd/issues/new). Be sure to fill out the issue template with a **title and clear description**, and as much relevant information as possible (e.g. deployment setup, browser version, etc).
#### **Did you write a patch that fixes a bug or implements a new feature?**
- Open a new pull request with the patch.
- Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable.
- Ensure all status checks pass. PR's with test failures will not be merged. PR's with insufficient coverage may be merged depending on the situation.
#### **Did you fix whitespace, format code, or make a purely cosmetic patch?**
Changes that are cosmetic in nature and do not add anything substantial to the stability, functionality, or testability of CTFd will generally not be accepted.

View File

@@ -1,313 +0,0 @@
import datetime
import os
import sys
import weakref
from distutils.version import StrictVersion
import jinja2
from flask import Flask, Request
from flask.helpers import safe_join
from flask_babel import Babel
from flask_migrate import upgrade
from jinja2 import FileSystemLoader
from jinja2.sandbox import SandboxedEnvironment
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.utils import cached_property
import CTFd.utils.config
from CTFd import utils
from CTFd.constants.themes import ADMIN_THEME, DEFAULT_THEME
from CTFd.plugins import init_plugins
from CTFd.utils.crypto import sha256
from CTFd.utils.initialization import (
init_events,
init_logs,
init_request_processors,
init_template_filters,
init_template_globals,
)
from CTFd.utils.migrations import create_database, migrations, stamp_latest_revision
from CTFd.utils.sessions import CachingSessionInterface
from CTFd.utils.updates import update_check
from CTFd.utils.user import get_locale
__version__ = "3.5.2"
__channel__ = "oss"
class CTFdRequest(Request):
@cached_property
def path(self):
"""
Hijack the original Flask request path because it does not account for subdirectory deployments in an intuitive
manner. We append script_root so that the path always points to the full path as seen in the browser.
e.g. /subdirectory/path/route vs /path/route
:return: string
"""
return self.script_root + super(CTFdRequest, self).path
class CTFdFlask(Flask):
def __init__(self, *args, **kwargs):
"""Overriden Jinja constructor setting a custom jinja_environment"""
self.jinja_environment = SandboxedBaseEnvironment
self.session_interface = CachingSessionInterface(key_prefix="session")
self.request_class = CTFdRequest
# Store server start time
self.start_time = datetime.datetime.utcnow()
# Create generally unique run identifier
self.run_id = sha256(str(self.start_time))[0:8]
Flask.__init__(self, *args, **kwargs)
def create_jinja_environment(self):
"""Overridden jinja environment constructor"""
return super(CTFdFlask, self).create_jinja_environment()
class SandboxedBaseEnvironment(SandboxedEnvironment):
"""SandboxEnvironment that mimics the Flask BaseEnvironment"""
def __init__(self, app, **options):
if "loader" not in options:
options["loader"] = app.create_global_jinja_loader()
SandboxedEnvironment.__init__(self, **options)
self.app = app
def _load_template(self, name, globals):
if self.loader is None:
raise TypeError("no loader for this environment specified")
# Add theme to the LRUCache cache key
cache_name = name
if name.startswith("admin/") is False:
theme = str(utils.get_config("ctf_theme"))
cache_name = theme + "/" + name
# Rest of this code is copied from Jinja
# https://github.com/pallets/jinja/blob/master/src/jinja2/environment.py#L802-L815
cache_key = (weakref.ref(self.loader), cache_name)
if self.cache is not None:
template = self.cache.get(cache_key)
if template is not None and (
not self.auto_reload or template.is_up_to_date
):
return template
template = self.loader.load(self, name, globals)
if self.cache is not None:
self.cache[cache_key] = template
return template
class ThemeLoader(FileSystemLoader):
"""Custom FileSystemLoader that is aware of theme structure and config.
"""
DEFAULT_THEMES_PATH = os.path.join(os.path.dirname(__file__), "themes")
_ADMIN_THEME_PREFIX = ADMIN_THEME + "/"
def __init__(
self,
searchpath=DEFAULT_THEMES_PATH,
theme_name=None,
encoding="utf-8",
followlinks=False,
):
super(ThemeLoader, self).__init__(searchpath, encoding, followlinks)
self.theme_name = theme_name
def get_source(self, environment, template):
# Refuse to load `admin/*` from a loader not for the admin theme
# Because there is a single template loader, themes can essentially
# provide files for other themes. This could end up causing issues if
# an admin theme references a file that doesn't exist that a malicious
# theme provides.
if template.startswith(self._ADMIN_THEME_PREFIX):
if self.theme_name != ADMIN_THEME:
raise jinja2.TemplateNotFound(template)
template = template[len(self._ADMIN_THEME_PREFIX) :]
theme_name = self.theme_name or str(utils.get_config("ctf_theme"))
template = safe_join(theme_name, "templates", template)
return super(ThemeLoader, self).get_source(environment, template)
def confirm_upgrade():
if sys.stdin.isatty():
print("/*\\ CTFd has updated and must update the database! /*\\")
print("/*\\ Please backup your database before proceeding! /*\\")
print("/*\\ CTFd maintainers are not responsible for any data loss! /*\\")
if input("Run database migrations (Y/N)").lower().strip() == "y": # nosec B322
return True
else:
print("/*\\ Ignored database migrations... /*\\")
return False
else:
return True
def run_upgrade():
upgrade()
utils.set_config("ctf_version", __version__)
def create_app(config="CTFd.config.Config"):
app = CTFdFlask(__name__)
with app.app_context():
app.config.from_object(config)
loaders = []
# We provide a `DictLoader` which may be used to override templates
app.overridden_templates = {}
loaders.append(jinja2.DictLoader(app.overridden_templates))
# A `ThemeLoader` with no `theme_name` will load from the current theme
loaders.append(ThemeLoader())
# If `THEME_FALLBACK` is set and true, we add another loader which will
# load from the `DEFAULT_THEME` - this mirrors the order implemented by
# `config.ctf_theme_candidates()`
if bool(app.config.get("THEME_FALLBACK")):
loaders.append(ThemeLoader(theme_name=DEFAULT_THEME))
# All themes including admin can be accessed by prefixing their name
prefix_loader_dict = {ADMIN_THEME: ThemeLoader(theme_name=ADMIN_THEME)}
for theme_name in CTFd.utils.config.get_themes():
prefix_loader_dict[theme_name] = ThemeLoader(theme_name=theme_name)
loaders.append(jinja2.PrefixLoader(prefix_loader_dict))
# Plugin templates are also accessed via prefix but we just point a
# normal `FileSystemLoader` at the plugin tree rather than validating
# each plugin here (that happens later in `init_plugins()`). We
# deliberately don't add this to `prefix_loader_dict` defined above
# because to do so would break template loading from a theme called
# `prefix` (even though that'd be weird).
plugin_loader = jinja2.FileSystemLoader(
searchpath=os.path.join(app.root_path, "plugins"), followlinks=True
)
loaders.append(jinja2.PrefixLoader({"plugins": plugin_loader}))
# Use a choice loader to find the first match from our list of loaders
app.jinja_loader = jinja2.ChoiceLoader(loaders)
from CTFd.models import ( # noqa: F401
Challenges,
Fails,
Files,
Flags,
Solves,
Tags,
Teams,
Tracking,
db,
)
url = create_database()
# This allows any changes to the SQLALCHEMY_DATABASE_URI to get pushed back in
# This is mostly so we can force MySQL's charset
app.config["SQLALCHEMY_DATABASE_URI"] = str(url)
# Register database
db.init_app(app)
# Register Flask-Migrate
migrations.init_app(app, db)
babel = Babel()
babel.locale_selector_func = get_locale
babel.init_app(app)
# Alembic sqlite support is lacking so we should just create_all anyway
if url.drivername.startswith("sqlite"):
# Enable foreign keys for SQLite. This must be before the
# db.create_all call because tests use the in-memory SQLite
# database (each connection, including db creation, is a new db).
# https://docs.sqlalchemy.org/en/13/dialects/sqlite.html#foreign-key-support
from sqlalchemy import event
from sqlalchemy.engine import Engine
@event.listens_for(Engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
db.create_all()
stamp_latest_revision()
else:
# This creates tables instead of db.create_all()
# Allows migrations to happen properly
upgrade()
from CTFd.models import ma
ma.init_app(app)
app.db = db
app.VERSION = __version__
app.CHANNEL = __channel__
from CTFd.cache import cache
cache.init_app(app)
app.cache = cache
reverse_proxy = app.config.get("REVERSE_PROXY")
if reverse_proxy:
if type(reverse_proxy) is str and "," in reverse_proxy:
proxyfix_args = [int(i) for i in reverse_proxy.split(",")]
app.wsgi_app = ProxyFix(app.wsgi_app, *proxyfix_args)
else:
app.wsgi_app = ProxyFix(
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1
)
version = utils.get_config("ctf_version")
# Upgrading from an older version of CTFd
if version and (StrictVersion(version) < StrictVersion(__version__)):
if confirm_upgrade():
run_upgrade()
else:
exit()
if not version:
utils.set_config("ctf_version", __version__)
if not utils.get_config("ctf_theme"):
utils.set_config("ctf_theme", DEFAULT_THEME)
update_check(force=True)
init_request_processors(app)
init_template_filters(app)
init_template_globals(app)
# Importing here allows tests to use sensible names (e.g. api instead of api_bp)
from CTFd.admin import admin
from CTFd.api import api
from CTFd.auth import auth
from CTFd.challenges import challenges
from CTFd.errors import render_error
from CTFd.events import events
from CTFd.scoreboard import scoreboard
from CTFd.teams import teams
from CTFd.users import users
from CTFd.views import views
app.register_blueprint(views)
app.register_blueprint(teams)
app.register_blueprint(users)
app.register_blueprint(challenges)
app.register_blueprint(scoreboard)
app.register_blueprint(auth)
app.register_blueprint(api)
app.register_blueprint(events)
app.register_blueprint(admin)
for code in {403, 404, 500, 502}:
app.register_error_handler(code, render_error)
init_logs(app)
init_events(app)
init_plugins(app)
return app

View File

@@ -1,257 +0,0 @@
import csv # noqa: I001
import datetime
import os
from io import StringIO
from flask import Blueprint, abort
from flask import current_app as app
from flask import (
jsonify,
redirect,
render_template,
render_template_string,
request,
send_file,
url_for,
)
admin = Blueprint("admin", __name__)
# isort:imports-firstparty
from CTFd.admin import challenges # noqa: F401,I001
from CTFd.admin import notifications # noqa: F401,I001
from CTFd.admin import pages # noqa: F401,I001
from CTFd.admin import scoreboard # noqa: F401,I001
from CTFd.admin import statistics # noqa: F401,I001
from CTFd.admin import submissions # noqa: F401,I001
from CTFd.admin import teams # noqa: F401,I001
from CTFd.admin import users # noqa: F401,I001
from CTFd.cache import (
cache,
clear_challenges,
clear_config,
clear_pages,
clear_standings,
)
from CTFd.models import (
Awards,
Challenges,
Configs,
Notifications,
Pages,
Solves,
Submissions,
Teams,
Tracking,
Unlocks,
Users,
db,
)
from CTFd.utils import config as ctf_config
from CTFd.utils import get_config, set_config
from CTFd.utils.csv import dump_csv, load_challenges_csv, load_teams_csv, load_users_csv
from CTFd.utils.decorators import admins_only
from CTFd.utils.exports import background_import_ctf
from CTFd.utils.exports import export_ctf as export_ctf_util
from CTFd.utils.security.auth import logout_user
from CTFd.utils.uploads import delete_file
from CTFd.utils.user import is_admin
@admin.route("/admin", methods=["GET"])
def view():
if is_admin():
return redirect(url_for("admin.statistics"))
return redirect(url_for("auth.login"))
@admin.route("/admin/plugins/<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 = u"{}.{}.zip".format(ctf_name, day)
return send_file(
backup, cache_timeout=-1, as_attachment=True, attachment_filename=full_name
)
@admin.route("/admin/import/csv", methods=["POST"])
@admins_only
def import_csv():
csv_type = request.form["csv_type"]
# Try really hard to load data in properly no matter what nonsense Excel gave you
raw = request.files["csv_file"].stream.read()
try:
csvdata = raw.decode("utf-8-sig")
except UnicodeDecodeError:
try:
csvdata = raw.decode("cp1252")
except UnicodeDecodeError:
csvdata = raw.decode("latin-1")
csvfile = StringIO(csvdata)
loaders = {
"challenges": load_challenges_csv,
"users": load_users_csv,
"teams": load_teams_csv,
}
loader = loaders[csv_type]
reader = csv.DictReader(csvfile)
success = loader(reader)
if success is True:
return redirect(url_for("admin.config"))
else:
return jsonify(success), 500
@admin.route("/admin/export/csv")
@admins_only
def export_csv():
table = request.args.get("table")
output = dump_csv(name=table)
return send_file(
output,
as_attachment=True,
cache_timeout=-1,
attachment_filename="{name}-{table}.csv".format(
name=ctf_config.ctf_name(), table=table
),
)
@admin.route("/admin/config", methods=["GET", "POST"])
@admins_only
def config():
# Clear the config cache so that we don't get stale values
clear_config()
configs = Configs.query.all()
configs = {c.key: get_config(c.key) for c in configs}
themes = ctf_config.get_themes()
# Remove current theme but ignore failure
try:
themes.remove(get_config("ctf_theme"))
except ValueError:
pass
return render_template("admin/config.html", themes=themes, **configs)
@admin.route("/admin/reset", methods=["GET", "POST"])
@admins_only
def reset():
if request.method == "POST":
require_setup = False
logout = False
next_url = url_for("admin.statistics")
data = request.form
if data.get("pages"):
_pages = Pages.query.all()
for p in _pages:
for f in p.files:
delete_file(file_id=f.id)
Pages.query.delete()
if data.get("notifications"):
Notifications.query.delete()
if data.get("challenges"):
_challenges = Challenges.query.all()
for c in _challenges:
for f in c.files:
delete_file(file_id=f.id)
Challenges.query.delete()
if data.get("accounts"):
Users.query.delete()
Teams.query.delete()
require_setup = True
logout = True
if data.get("submissions"):
Solves.query.delete()
Submissions.query.delete()
Awards.query.delete()
Unlocks.query.delete()
Tracking.query.delete()
if require_setup:
set_config("setup", False)
cache.clear()
logout_user()
next_url = url_for("views.setup")
db.session.commit()
clear_pages()
clear_standings()
clear_challenges()
clear_config()
if logout is True:
cache.clear()
logout_user()
db.session.close()
return redirect(next_url)
return render_template("admin/reset.html")

View File

@@ -1,78 +0,0 @@
from flask import abort, render_template, request, url_for
from CTFd.admin import admin
from CTFd.models import Challenges, Flags, Solves
from CTFd.plugins.challenges import CHALLENGE_CLASSES, get_chal_class
from CTFd.utils.decorators import admins_only
@admin.route("/admin/challenges")
@admins_only
def challenges_listing():
q = request.args.get("q")
field = request.args.get("field")
filters = []
if q:
# The field exists as an exposed column
if Challenges.__mapper__.has_property(field):
filters.append(getattr(Challenges, field).like("%{}%".format(q)))
query = Challenges.query.filter(*filters).order_by(Challenges.id.asc())
challenges = query.all()
total = query.count()
return render_template(
"admin/challenges/challenges.html",
challenges=challenges,
total=total,
q=q,
field=field,
)
@admin.route("/admin/challenges/<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/new")
@admins_only
def challenges_new():
types = CHALLENGE_CLASSES.keys()
return render_template("admin/challenges/new.html", types=types)

View File

@@ -1,12 +0,0 @@
from flask import render_template
from CTFd.admin import admin
from CTFd.models import Notifications
from CTFd.utils.decorators import admins_only
@admin.route("/admin/notifications")
@admins_only
def notifications():
notifs = Notifications.query.order_by(Notifications.id.desc()).all()
return render_template("admin/notifications.html", notifications=notifs)

View File

@@ -1,49 +0,0 @@
from flask import render_template, request
from CTFd.admin import admin
from CTFd.models import Pages
from CTFd.schemas.pages import PageSchema
from CTFd.utils import markdown
from CTFd.utils.decorators import admins_only
@admin.route("/admin/pages")
@admins_only
def pages_listing():
pages = Pages.query.all()
return render_template("admin/pages.html", pages=pages)
@admin.route("/admin/pages/new")
@admins_only
def pages_new():
return render_template("admin/editor.html")
@admin.route("/admin/pages/preview", methods=["POST"])
@admins_only
def pages_preview():
# We only care about content.
# Loading other attributes improperly will cause Marshmallow to incorrectly return a dict
data = {
"content": request.form.get("content"),
"format": request.form.get("format"),
}
schema = PageSchema()
page = schema.load(data)
return render_template("page.html", content=page.data.html)
@admin.route("/admin/pages/<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)

View File

@@ -1,16 +0,0 @@
from flask import render_template
from CTFd.admin import admin
from CTFd.utils.config import is_teams_mode
from CTFd.utils.decorators import admins_only
from CTFd.utils.scores import get_standings, get_user_standings
@admin.route("/admin/scoreboard")
@admins_only
def scoreboard_listing():
standings = get_standings(admin=True)
user_standings = get_user_standings(admin=True) if is_teams_mode() else None
return render_template(
"admin/scoreboard.html", standings=standings, user_standings=user_standings
)

View File

@@ -1,87 +0,0 @@
from flask import render_template
from CTFd.admin import admin
from CTFd.models import Challenges, Fails, Solves, Teams, Tracking, Users, db
from CTFd.utils.decorators import admins_only
from CTFd.utils.modes import get_model
from CTFd.utils.updates import update_check
@admin.route("/admin/statistics", methods=["GET"])
@admins_only
def statistics():
update_check()
Model = get_model()
teams_registered = Teams.query.count()
users_registered = Users.query.count()
wrong_count = (
Fails.query.join(Model, Fails.account_id == Model.id)
.filter(Model.banned == False, Model.hidden == False)
.count()
)
solve_count = (
Solves.query.join(Model, Solves.account_id == Model.id)
.filter(Model.banned == False, Model.hidden == False)
.count()
)
challenge_count = Challenges.query.count()
total_points = (
Challenges.query.with_entities(db.func.sum(Challenges.value).label("sum"))
.filter_by(state="visible")
.first()
.sum
) or 0
ip_count = Tracking.query.with_entities(Tracking.ip).distinct().count()
solves_sub = (
db.session.query(
Solves.challenge_id, db.func.count(Solves.challenge_id).label("solves_cnt")
)
.join(Model, Solves.account_id == Model.id)
.filter(Model.banned == False, Model.hidden == False)
.group_by(Solves.challenge_id)
.subquery()
)
solves = (
db.session.query(
solves_sub.columns.challenge_id,
solves_sub.columns.solves_cnt,
Challenges.name,
)
.join(Challenges, solves_sub.columns.challenge_id == Challenges.id)
.all()
)
solve_data = {}
for _chal, count, name in solves:
solve_data[name] = count
most_solved = None
least_solved = None
if len(solve_data):
most_solved = max(solve_data, key=solve_data.get)
least_solved = min(solve_data, key=solve_data.get)
db.session.close()
return render_template(
"admin/statistics.html",
user_count=users_registered,
team_count=teams_registered,
ip_count=ip_count,
wrong_count=wrong_count,
solve_count=solve_count,
challenge_count=challenge_count,
total_points=total_points,
solve_data=solve_data,
most_solved=most_solved,
least_solved=least_solved,
)

View File

@@ -1,65 +0,0 @@
from flask import render_template, request, url_for
from CTFd.admin import admin
from CTFd.models import Challenges, Submissions
from CTFd.utils.decorators import admins_only
from CTFd.utils.helpers.models import build_model_filters
from CTFd.utils.modes import get_model
@admin.route("/admin/submissions", defaults={"submission_type": None})
@admin.route("/admin/submissions/<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)
)
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,
)

View File

@@ -1,86 +0,0 @@
from flask import render_template, request, url_for
from sqlalchemy.sql import not_
from CTFd.admin import admin
from CTFd.models import Challenges, Teams, Tracking
from CTFd.utils.decorators import admins_only
@admin.route("/admin/teams")
@admins_only
def teams_listing():
q = request.args.get("q")
field = request.args.get("field")
page = abs(request.args.get("page", 1, type=int))
filters = []
if q:
# The field exists as an exposed column
if Teams.__mapper__.has_property(field):
filters.append(getattr(Teams, field).like("%{}%".format(q)))
teams = (
Teams.query.filter(*filters)
.order_by(Teams.id.asc())
.paginate(page=page, per_page=50)
)
args = dict(request.args)
args.pop("page", 1)
return render_template(
"admin/teams/teams.html",
teams=teams,
prev_page=url_for(request.endpoint, page=teams.prev_num, **args),
next_page=url_for(request.endpoint, page=teams.next_num, **args),
q=q,
field=field,
)
@admin.route("/admin/teams/new")
@admins_only
def teams_new():
return render_template("admin/teams/new.html")
@admin.route("/admin/teams/<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,
)

View File

@@ -1,109 +0,0 @@
from flask import render_template, request, url_for
from sqlalchemy.sql import not_
from CTFd.admin import admin
from CTFd.models import Challenges, Tracking, Users
from CTFd.utils import get_config
from CTFd.utils.decorators import admins_only
from CTFd.utils.modes import TEAMS_MODE
@admin.route("/admin/users")
@admins_only
def users_listing():
q = request.args.get("q")
field = request.args.get("field")
page = abs(request.args.get("page", 1, type=int))
filters = []
users = []
if q:
# The field exists as an exposed column
if Users.__mapper__.has_property(field):
filters.append(getattr(Users, field).like("%{}%".format(q)))
if q and field == "ip":
users = (
Users.query.join(Tracking, Users.id == Tracking.user_id)
.filter(Tracking.ip.like("%{}%".format(q)))
.order_by(Users.id.asc())
.paginate(page=page, per_page=50)
)
else:
users = (
Users.query.filter(*filters)
.order_by(Users.id.asc())
.paginate(page=page, per_page=50)
)
args = dict(request.args)
args.pop("page", 1)
return render_template(
"admin/users/users.html",
users=users,
prev_page=url_for(request.endpoint, page=users.prev_num, **args),
next_page=url_for(request.endpoint, page=users.next_num, **args),
q=q,
field=field,
)
@admin.route("/admin/users/new")
@admins_only
def users_new():
return render_template("admin/users/new.html")
@admin.route("/admin/users/<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,
)

View File

@@ -1,73 +0,0 @@
from flask import Blueprint, current_app
from flask_restx import Api
from CTFd.api.v1.awards import awards_namespace
from CTFd.api.v1.challenges import challenges_namespace
from CTFd.api.v1.comments import comments_namespace
from CTFd.api.v1.config import configs_namespace
from CTFd.api.v1.files import files_namespace
from CTFd.api.v1.flags import flags_namespace
from CTFd.api.v1.hints import hints_namespace
from CTFd.api.v1.notifications import notifications_namespace
from CTFd.api.v1.pages import pages_namespace
from CTFd.api.v1.schemas import (
APIDetailedSuccessResponse,
APISimpleErrorResponse,
APISimpleSuccessResponse,
)
from CTFd.api.v1.scoreboard import scoreboard_namespace
from CTFd.api.v1.statistics import statistics_namespace
from CTFd.api.v1.submissions import submissions_namespace
from CTFd.api.v1.tags import tags_namespace
from CTFd.api.v1.teams import teams_namespace
from CTFd.api.v1.tokens import tokens_namespace
from CTFd.api.v1.topics import topics_namespace
from CTFd.api.v1.unlocks import unlocks_namespace
from CTFd.api.v1.users import users_namespace
api = Blueprint("api", __name__, url_prefix="/api/v1")
CTFd_API_v1 = Api(
api,
version="v1",
doc=current_app.config.get("SWAGGER_UI_ENDPOINT"),
authorizations={
"AccessToken": {
"type": "apiKey",
"in": "header",
"name": "Authorization",
"description": "Generate access token in the settings page of your user account.",
},
"ContentType": {
"type": "apiKey",
"in": "header",
"name": "Content-Type",
"description": "Must be set to `application/json`",
},
},
security=["AccessToken", "ContentType"],
)
CTFd_API_v1.schema_model("APISimpleErrorResponse", APISimpleErrorResponse.schema())
CTFd_API_v1.schema_model(
"APIDetailedSuccessResponse", APIDetailedSuccessResponse.schema()
)
CTFd_API_v1.schema_model("APISimpleSuccessResponse", APISimpleSuccessResponse.schema())
CTFd_API_v1.add_namespace(challenges_namespace, "/challenges")
CTFd_API_v1.add_namespace(tags_namespace, "/tags")
CTFd_API_v1.add_namespace(topics_namespace, "/topics")
CTFd_API_v1.add_namespace(awards_namespace, "/awards")
CTFd_API_v1.add_namespace(hints_namespace, "/hints")
CTFd_API_v1.add_namespace(flags_namespace, "/flags")
CTFd_API_v1.add_namespace(submissions_namespace, "/submissions")
CTFd_API_v1.add_namespace(scoreboard_namespace, "/scoreboard")
CTFd_API_v1.add_namespace(teams_namespace, "/teams")
CTFd_API_v1.add_namespace(users_namespace, "/users")
CTFd_API_v1.add_namespace(statistics_namespace, "/statistics")
CTFd_API_v1.add_namespace(files_namespace, "/files")
CTFd_API_v1.add_namespace(notifications_namespace, "/notifications")
CTFd_API_v1.add_namespace(configs_namespace, "/configs")
CTFd_API_v1.add_namespace(pages_namespace, "/pages")
CTFd_API_v1.add_namespace(unlocks_namespace, "/unlocks")
CTFd_API_v1.add_namespace(tokens_namespace, "/tokens")
CTFd_API_v1.add_namespace(comments_namespace, "/comments")

View File

@@ -1,177 +0,0 @@
from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.request import validate_args
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.cache import clear_standings
from CTFd.constants import RawEnum
from CTFd.models import Awards, Users, db
from CTFd.schemas.awards import AwardSchema
from CTFd.utils.config import is_teams_mode
from CTFd.utils.decorators import admins_only
from CTFd.utils.helpers.models import build_model_filters
awards_namespace = Namespace("awards", description="Endpoint to retrieve Awards")
AwardModel = sqlalchemy_to_pydantic(Awards)
class AwardDetailedSuccessResponse(APIDetailedSuccessResponse):
data: AwardModel
class AwardListSuccessResponse(APIListSuccessResponse):
data: List[AwardModel]
awards_namespace.schema_model(
"AwardDetailedSuccessResponse", AwardDetailedSuccessResponse.apidoc()
)
awards_namespace.schema_model(
"AwardListSuccessResponse", AwardListSuccessResponse.apidoc()
)
@awards_namespace.route("")
class AwardList(Resource):
@admins_only
@awards_namespace.doc(
description="Endpoint to list Award objects in bulk",
responses={
200: ("Success", "AwardListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
@validate_args(
{
"user_id": (int, None),
"team_id": (int, None),
"type": (str, None),
"value": (int, None),
"category": (int, None),
"icon": (int, None),
"q": (str, None),
"field": (
RawEnum(
"AwardFields",
{
"name": "name",
"description": "description",
"category": "category",
"icon": "icon",
},
),
None,
),
},
location="query",
)
def get(self, query_args):
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
filters = build_model_filters(model=Awards, query=q, field=field)
awards = Awards.query.filter_by(**query_args).filter(*filters).all()
schema = AwardSchema(many=True)
response = schema.dump(awards)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
@admins_only
@awards_namespace.doc(
description="Endpoint to create an Award object",
responses={
200: ("Success", "AwardListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self):
req = request.get_json()
# Force a team_id if in team mode and unspecified
if is_teams_mode():
team_id = req.get("team_id")
if team_id is None:
user = Users.query.filter_by(id=req["user_id"]).first_or_404()
if user.team_id is None:
return (
{
"success": False,
"errors": {
"team_id": [
"User doesn't have a team to associate award with"
]
},
},
400,
)
req["team_id"] = user.team_id
schema = AwardSchema()
response = schema.load(req, session=db.session)
if response.errors:
return {"success": False, "errors": response.errors}, 400
db.session.add(response.data)
db.session.commit()
response = schema.dump(response.data)
db.session.close()
# Delete standings cache because awards can change scores
clear_standings()
return {"success": True, "data": response.data}
@awards_namespace.route("/<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}

View File

@@ -1,813 +0,0 @@
from typing import List # noqa: I001
from flask import abort, render_template, request, url_for
from flask_restx import Namespace, Resource
from sqlalchemy.sql import and_
from CTFd.api.v1.helpers.request import validate_args
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.cache import clear_challenges, clear_standings
from CTFd.constants import RawEnum
from CTFd.models import ChallengeFiles as ChallengeFilesModel
from CTFd.models import Challenges
from CTFd.models import ChallengeTopics as ChallengeTopicsModel
from CTFd.models import Fails, Flags, Hints, HintUnlocks, Solves, Submissions, Tags, db
from CTFd.plugins.challenges import CHALLENGE_CLASSES, get_chal_class
from CTFd.schemas.challenges import ChallengeSchema
from CTFd.schemas.flags import FlagSchema
from CTFd.schemas.hints import HintSchema
from CTFd.schemas.tags import TagSchema
from CTFd.utils import config, get_config
from CTFd.utils import user as current_user
from CTFd.utils.challenges import (
get_all_challenges,
get_solve_counts_for_challenges,
get_solve_ids_for_user_id,
get_solves_for_challenge_id,
)
from CTFd.utils.config.visibility import (
accounts_visible,
challenges_visible,
scores_visible,
)
from CTFd.utils.dates import ctf_ended, ctf_paused, ctftime
from CTFd.utils.decorators import (
admins_only,
during_ctf_time_only,
require_verified_emails,
)
from CTFd.utils.decorators.visibility import (
check_challenge_visibility,
check_score_visibility,
)
from CTFd.utils.logging import log
from CTFd.utils.security.signing import serialize
from CTFd.utils.user import (
authed,
get_current_team,
get_current_team_attrs,
get_current_user,
get_current_user_attrs,
is_admin,
)
challenges_namespace = Namespace(
"challenges", description="Endpoint to retrieve Challenges"
)
ChallengeModel = sqlalchemy_to_pydantic(
Challenges, include={"solves": int, "solved_by_me": bool}
)
TransientChallengeModel = sqlalchemy_to_pydantic(Challenges, exclude=["id"])
class ChallengeDetailedSuccessResponse(APIDetailedSuccessResponse):
data: ChallengeModel
class ChallengeListSuccessResponse(APIListSuccessResponse):
data: List[ChallengeModel]
challenges_namespace.schema_model(
"ChallengeDetailedSuccessResponse", ChallengeDetailedSuccessResponse.apidoc()
)
challenges_namespace.schema_model(
"ChallengeListSuccessResponse", ChallengeListSuccessResponse.apidoc()
)
@challenges_namespace.route("")
class ChallengeList(Resource):
@check_challenge_visibility
@during_ctf_time_only
@require_verified_emails
@challenges_namespace.doc(
description="Endpoint to get Challenge objects in bulk",
responses={
200: ("Success", "ChallengeListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
@validate_args(
{
"name": (str, None),
"max_attempts": (int, None),
"value": (int, None),
"category": (str, None),
"type": (str, None),
"state": (str, None),
"q": (str, None),
"field": (
RawEnum(
"ChallengeFields",
{
"name": "name",
"description": "description",
"category": "category",
"type": "type",
"state": "state",
},
),
None,
),
},
location="query",
)
def get(self, query_args):
# Require a team if in teams mode
# TODO: Convert this into a re-useable decorator
# TODO: The require_team decorator doesnt work because of no admin passthru
if get_current_user_attrs():
if is_admin():
pass
else:
if config.is_teams_mode() and get_current_team_attrs() is None:
abort(403)
# Build filtering queries
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
# Admins get a shortcut to see all challenges despite pre-requisites
admin_view = is_admin() and request.args.get("view") == "admin"
# Get a cached mapping of challenge_id to solve_count
solve_counts = get_solve_counts_for_challenges(admin=admin_view)
# Get list of solve_ids for current user
if authed():
user = get_current_user()
user_solves = get_solve_ids_for_user_id(user_id=user.id)
else:
user_solves = set()
# Aggregate the query results into the hashes defined at the top of
# this block for later use
if scores_visible() and accounts_visible():
solve_count_dfl = 0
else:
# Empty out the solves_count if we're hiding scores/accounts
solve_counts = {}
# This is necessary to match the challenge detail API which returns
# `None` for the solve count if visiblity checks fail
solve_count_dfl = None
chal_q = get_all_challenges(admin=admin_view, field=field, q=q, **query_args)
# Iterate through the list of challenges, adding to the object which
# will be JSONified back to the client
response = []
tag_schema = TagSchema(view="user", many=True)
# Gather all challenge IDs so that we can determine invalid challenge prereqs
all_challenge_ids = {
c.id for c in Challenges.query.with_entities(Challenges.id).all()
}
for challenge in chal_q:
if challenge.requirements:
requirements = challenge.requirements.get("prerequisites", [])
anonymize = challenge.requirements.get("anonymize")
prereqs = set(requirements).intersection(all_challenge_ids)
if user_solves >= prereqs or admin_view:
pass
else:
if anonymize:
response.append(
{
"id": challenge.id,
"type": "hidden",
"name": "???",
"value": 0,
"solves": None,
"solved_by_me": False,
"category": "???",
"tags": [],
"template": "",
"script": "",
}
)
# Fallthrough to continue
continue
try:
challenge_type = get_chal_class(challenge.type)
except KeyError:
# Challenge type does not exist. Fall through to next challenge.
continue
# Challenge passes all checks, add it to response
response.append(
{
"id": challenge.id,
"type": challenge_type.name,
"name": challenge.name,
"value": challenge.value,
"solves": solve_counts.get(challenge.id, solve_count_dfl),
"solved_by_me": challenge.id in user_solves,
"category": challenge.category,
"tags": tag_schema.dump(challenge.tags).data,
"template": challenge_type.templates["view"],
"script": challenge_type.scripts["view"],
}
)
db.session.close()
return {"success": True, "data": response}
@admins_only
@challenges_namespace.doc(
description="Endpoint to create a Challenge object",
responses={
200: ("Success", "ChallengeDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self):
data = request.form or request.get_json()
# Load data through schema for validation but not for insertion
schema = ChallengeSchema()
response = schema.load(data)
if response.errors:
return {"success": False, "errors": response.errors}, 400
challenge_type = data["type"]
challenge_class = get_chal_class(challenge_type)
challenge = challenge_class.create(request)
response = challenge_class.read(challenge)
clear_challenges()
return {"success": True, "data": response}
@challenges_namespace.route("/types")
class ChallengeTypes(Resource):
@admins_only
def get(self):
response = {}
for class_id in CHALLENGE_CLASSES:
challenge_class = CHALLENGE_CLASSES.get(class_id)
response[challenge_class.id] = {
"id": challenge_class.id,
"name": challenge_class.name,
"templates": challenge_class.templates,
"scripts": challenge_class.scripts,
"create": render_template(
challenge_class.templates["create"].lstrip("/")
),
}
return {"success": True, "data": response}
@challenges_namespace.route("/<challenge_id>")
class Challenge(Resource):
@check_challenge_visibility
@during_ctf_time_only
@require_verified_emails
@challenges_namespace.doc(
description="Endpoint to get a specific Challenge object",
responses={
200: ("Success", "ChallengeDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self, challenge_id):
if is_admin():
chal = Challenges.query.filter(Challenges.id == challenge_id).first_or_404()
else:
chal = Challenges.query.filter(
Challenges.id == challenge_id,
and_(Challenges.state != "hidden", Challenges.state != "locked"),
).first_or_404()
try:
chal_class = get_chal_class(chal.type)
except KeyError:
abort(
500,
f"The underlying challenge type ({chal.type}) is not installed. This challenge can not be loaded.",
)
if chal.requirements:
requirements = chal.requirements.get("prerequisites", [])
anonymize = chal.requirements.get("anonymize")
# Gather all challenge IDs so that we can determine invalid challenge prereqs
all_challenge_ids = {
c.id for c in Challenges.query.with_entities(Challenges.id).all()
}
if challenges_visible():
user = get_current_user()
if user:
solve_ids = (
Solves.query.with_entities(Solves.challenge_id)
.filter_by(account_id=user.account_id)
.order_by(Solves.challenge_id.asc())
.all()
)
else:
# We need to handle the case where a user is viewing challenges anonymously
solve_ids = []
solve_ids = {value for value, in solve_ids}
prereqs = set(requirements).intersection(all_challenge_ids)
if solve_ids >= prereqs or is_admin():
pass
else:
if anonymize:
return {
"success": True,
"data": {
"id": chal.id,
"type": "hidden",
"name": "???",
"value": 0,
"solves": None,
"solved_by_me": False,
"category": "???",
"tags": [],
"template": "",
"script": "",
},
}
abort(403)
else:
abort(403)
tags = [
tag["value"] for tag in TagSchema("user", many=True).dump(chal.tags).data
]
unlocked_hints = set()
hints = []
if authed():
user = get_current_user()
team = get_current_team()
# TODO: Convert this into a re-useable decorator
if is_admin():
pass
else:
if config.is_teams_mode() and team is None:
abort(403)
unlocked_hints = {
u.target
for u in HintUnlocks.query.filter_by(
type="hints", account_id=user.account_id
)
}
files = []
for f in chal.files:
token = {
"user_id": user.id,
"team_id": team.id if team else None,
"file_id": f.id,
}
files.append(
url_for("views.files", path=f.location, token=serialize(token))
)
else:
files = [url_for("views.files", path=f.location) for f in chal.files]
for hint in Hints.query.filter_by(challenge_id=chal.id).all():
if hint.id in unlocked_hints or ctf_ended():
hints.append(
{"id": hint.id, "cost": hint.cost, "content": hint.content}
)
else:
hints.append({"id": hint.id, "cost": hint.cost})
response = chal_class.read(challenge=chal)
# Get list of solve_ids for current user
if authed():
user = get_current_user()
user_solves = get_solve_ids_for_user_id(user_id=user.id)
else:
user_solves = []
solves_count = get_solve_counts_for_challenges(challenge_id=chal.id)
if solves_count:
challenge_id = chal.id
solve_count = solves_count.get(chal.id)
solved_by_user = challenge_id in user_solves
else:
solve_count, solved_by_user = 0, False
# Hide solve counts if we are hiding solves/accounts
if scores_visible() is False or accounts_visible() is False:
solve_count = None
if authed():
# Get current attempts for the user
attempts = Submissions.query.filter_by(
account_id=user.account_id, challenge_id=challenge_id
).count()
else:
attempts = 0
response["solves"] = solve_count
response["solved_by_me"] = solved_by_user
response["attempts"] = attempts
response["files"] = files
response["tags"] = tags
response["hints"] = hints
response["view"] = render_template(
chal_class.templates["view"].lstrip("/"),
solves=solve_count,
solved_by_me=solved_by_user,
files=files,
tags=tags,
hints=[Hints(**h) for h in hints],
max_attempts=chal.max_attempts,
attempts=attempts,
challenge=chal,
)
db.session.close()
return {"success": True, "data": response}
@admins_only
@challenges_namespace.doc(
description="Endpoint to edit a specific Challenge object",
responses={
200: ("Success", "ChallengeDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def patch(self, challenge_id):
data = request.get_json()
# Load data through schema for validation but not for insertion
schema = ChallengeSchema()
response = schema.load(data)
if response.errors:
return {"success": False, "errors": response.errors}, 400
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
challenge_class = get_chal_class(challenge.type)
challenge = challenge_class.update(challenge, request)
response = challenge_class.read(challenge)
clear_standings()
clear_challenges()
return {"success": True, "data": response}
@admins_only
@challenges_namespace.doc(
description="Endpoint to delete a specific Challenge object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, challenge_id):
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
chal_class = get_chal_class(challenge.type)
chal_class.delete(challenge)
clear_standings()
clear_challenges()
return {"success": True}
@challenges_namespace.route("/attempt")
class ChallengeAttempt(Resource):
@check_challenge_visibility
@during_ctf_time_only
@require_verified_emails
def post(self):
if authed() is False:
return {"success": True, "data": {"status": "authentication_required"}}, 403
if request.content_type != "application/json":
request_data = request.form
else:
request_data = request.get_json()
challenge_id = request_data.get("challenge_id")
if current_user.is_admin():
preview = request.args.get("preview", False)
if preview:
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
chal_class = get_chal_class(challenge.type)
status, message = chal_class.attempt(challenge, request)
return {
"success": True,
"data": {
"status": "correct" if status else "incorrect",
"message": message,
},
}
if ctf_paused():
return (
{
"success": True,
"data": {
"status": "paused",
"message": "{} is paused".format(config.ctf_name()),
},
},
403,
)
user = get_current_user()
team = get_current_team()
# TODO: Convert this into a re-useable decorator
if config.is_teams_mode() and team is None:
abort(403)
fails = Fails.query.filter_by(
account_id=user.account_id, challenge_id=challenge_id
).count()
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
if challenge.state == "hidden":
abort(404)
if challenge.state == "locked":
abort(403)
if challenge.requirements:
requirements = challenge.requirements.get("prerequisites", [])
solve_ids = (
Solves.query.with_entities(Solves.challenge_id)
.filter_by(account_id=user.account_id)
.order_by(Solves.challenge_id.asc())
.all()
)
solve_ids = {solve_id for solve_id, in solve_ids}
# Gather all challenge IDs so that we can determine invalid challenge prereqs
all_challenge_ids = {
c.id for c in Challenges.query.with_entities(Challenges.id).all()
}
prereqs = set(requirements).intersection(all_challenge_ids)
if solve_ids >= prereqs:
pass
else:
abort(403)
chal_class = get_chal_class(challenge.type)
# Anti-bruteforce / submitting Flags too quickly
kpm = current_user.get_wrong_submissions_per_minute(user.account_id)
kpm_limit = int(get_config("incorrect_submissions_per_min", default=10))
if kpm > kpm_limit:
if ctftime():
chal_class.fail(
user=user, team=team, challenge=challenge, request=request
)
log(
"submissions",
"[{date}] {name} submitted {submission} on {challenge_id} with kpm {kpm} [TOO FAST]",
name=user.name,
submission=request_data.get("submission", "").encode("utf-8"),
challenge_id=challenge_id,
kpm=kpm,
)
# Submitting too fast
return (
{
"success": True,
"data": {
"status": "ratelimited",
"message": "You're submitting flags too fast. Slow down.",
},
},
429,
)
solves = Solves.query.filter_by(
account_id=user.account_id, challenge_id=challenge_id
).first()
# Challenge not solved yet
if not solves:
# Hit max attempts
max_tries = challenge.max_attempts
if max_tries and fails >= max_tries > 0:
return (
{
"success": True,
"data": {
"status": "incorrect",
"message": "You have 0 tries remaining",
},
},
403,
)
status, message = chal_class.attempt(challenge, request)
if status: # The challenge plugin says the input is right
if ctftime() or current_user.is_admin():
chal_class.solve(
user=user, team=team, challenge=challenge, request=request
)
clear_standings()
clear_challenges()
log(
"submissions",
"[{date}] {name} submitted {submission} on {challenge_id} with kpm {kpm} [CORRECT]",
name=user.name,
submission=request_data.get("submission", "").encode("utf-8"),
challenge_id=challenge_id,
kpm=kpm,
)
return {
"success": True,
"data": {"status": "correct", "message": message},
}
else: # The challenge plugin says the input is wrong
if ctftime() or current_user.is_admin():
chal_class.fail(
user=user, team=team, challenge=challenge, request=request
)
clear_standings()
clear_challenges()
log(
"submissions",
"[{date}] {name} submitted {submission} on {challenge_id} with kpm {kpm} [WRONG]",
name=user.name,
submission=request_data.get("submission", "").encode("utf-8"),
challenge_id=challenge_id,
kpm=kpm,
)
if max_tries:
# Off by one since fails has changed since it was gotten
attempts_left = max_tries - fails - 1
tries_str = "tries"
if attempts_left == 1:
tries_str = "try"
# Add a punctuation mark if there isn't one
if message[-1] not in "!().;?[]{}":
message = message + "."
return {
"success": True,
"data": {
"status": "incorrect",
"message": "{} You have {} {} remaining.".format(
message, attempts_left, tries_str
),
},
}
else:
return {
"success": True,
"data": {"status": "incorrect", "message": message},
}
# Challenge already solved
else:
log(
"submissions",
"[{date}] {name} submitted {submission} on {challenge_id} with kpm {kpm} [ALREADY SOLVED]",
name=user.name,
submission=request_data.get("submission", "").encode("utf-8"),
challenge_id=challenge_id,
kpm=kpm,
)
return {
"success": True,
"data": {
"status": "already_solved",
"message": "You already solved this",
},
}
@challenges_namespace.route("/<challenge_id>/solves")
class ChallengeSolves(Resource):
@check_challenge_visibility
@check_score_visibility
@during_ctf_time_only
@require_verified_emails
def get(self, challenge_id):
response = []
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
# TODO: Need a generic challenge visibility call.
# However, it should be stated that a solve on a gated challenge is not considered private.
if challenge.state == "hidden" and is_admin() is False:
abort(404)
freeze = get_config("freeze")
if freeze:
preview = request.args.get("preview")
if (is_admin() is False) or (is_admin() is True and preview):
freeze = True
elif is_admin() is True:
freeze = False
response = get_solves_for_challenge_id(challenge_id=challenge_id, freeze=freeze)
return {"success": True, "data": response}
@challenges_namespace.route("/<challenge_id>/files")
class ChallengeFiles(Resource):
@admins_only
def get(self, challenge_id):
response = []
challenge_files = ChallengeFilesModel.query.filter_by(
challenge_id=challenge_id
).all()
for f in challenge_files:
response.append({"id": f.id, "type": f.type, "location": f.location})
return {"success": True, "data": response}
@challenges_namespace.route("/<challenge_id>/tags")
class ChallengeTags(Resource):
@admins_only
def get(self, challenge_id):
response = []
tags = Tags.query.filter_by(challenge_id=challenge_id).all()
for t in tags:
response.append(
{"id": t.id, "challenge_id": t.challenge_id, "value": t.value}
)
return {"success": True, "data": response}
@challenges_namespace.route("/<challenge_id>/topics")
class ChallengeTopics(Resource):
@admins_only
def get(self, challenge_id):
response = []
topics = ChallengeTopicsModel.query.filter_by(challenge_id=challenge_id).all()
for t in topics:
response.append(
{
"id": t.id,
"challenge_id": t.challenge_id,
"topic_id": t.topic_id,
"value": t.topic.value,
}
)
return {"success": True, "data": response}
@challenges_namespace.route("/<challenge_id>/hints")
class ChallengeHints(Resource):
@admins_only
def get(self, challenge_id):
hints = Hints.query.filter_by(challenge_id=challenge_id).all()
schema = HintSchema(many=True)
response = schema.dump(hints)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
@challenges_namespace.route("/<challenge_id>/flags")
class ChallengeFlags(Resource):
@admins_only
def get(self, challenge_id):
flags = Flags.query.filter_by(challenge_id=challenge_id).all()
schema = FlagSchema(many=True)
response = schema.dump(flags)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
@challenges_namespace.route("/<challenge_id>/requirements")
class ChallengeRequirements(Resource):
@admins_only
def get(self, challenge_id):
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
return {"success": True, "data": challenge.requirements}

View File

@@ -1,159 +0,0 @@
from typing import List
from flask import request, session
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.request import validate_args
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.constants import RawEnum
from CTFd.models import (
ChallengeComments,
Comments,
PageComments,
TeamComments,
UserComments,
db,
)
from CTFd.schemas.comments import CommentSchema
from CTFd.utils.decorators import admins_only
from CTFd.utils.helpers.models import build_model_filters
comments_namespace = Namespace("comments", description="Endpoint to retrieve Comments")
CommentModel = sqlalchemy_to_pydantic(Comments)
class CommentDetailedSuccessResponse(APIDetailedSuccessResponse):
data: CommentModel
class CommentListSuccessResponse(APIListSuccessResponse):
data: List[CommentModel]
comments_namespace.schema_model(
"CommentDetailedSuccessResponse", CommentDetailedSuccessResponse.apidoc()
)
comments_namespace.schema_model(
"CommentListSuccessResponse", CommentListSuccessResponse.apidoc()
)
def get_comment_model(data):
model = Comments
if "challenge_id" in data:
model = ChallengeComments
elif "user_id" in data:
model = UserComments
elif "team_id" in data:
model = TeamComments
elif "page_id" in data:
model = PageComments
else:
model = Comments
return model
@comments_namespace.route("")
class CommentList(Resource):
@admins_only
@comments_namespace.doc(
description="Endpoint to list Comment objects in bulk",
responses={
200: ("Success", "CommentListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
@validate_args(
{
"challenge_id": (int, None),
"user_id": (int, None),
"team_id": (int, None),
"page_id": (int, None),
"q": (str, None),
"field": (RawEnum("CommentFields", {"content": "content"}), None),
},
location="query",
)
def get(self, query_args):
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
CommentModel = get_comment_model(data=query_args)
filters = build_model_filters(model=CommentModel, query=q, field=field)
comments = (
CommentModel.query.filter_by(**query_args)
.filter(*filters)
.order_by(CommentModel.id.desc())
.paginate(max_per_page=100)
)
schema = CommentSchema(many=True)
response = schema.dump(comments.items)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {
"meta": {
"pagination": {
"page": comments.page,
"next": comments.next_num,
"prev": comments.prev_num,
"pages": comments.pages,
"per_page": comments.per_page,
"total": comments.total,
}
},
"success": True,
"data": response.data,
}
@admins_only
@comments_namespace.doc(
description="Endpoint to create a Comment object",
responses={
200: ("Success", "CommentDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self):
req = request.get_json()
# Always force author IDs to be the actual user
req["author_id"] = session["id"]
CommentModel = get_comment_model(data=req)
m = CommentModel(**req)
db.session.add(m)
db.session.commit()
schema = CommentSchema()
response = schema.dump(m)
db.session.close()
return {"success": True, "data": response.data}
@comments_namespace.route("/<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}

View File

@@ -1,286 +0,0 @@
from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.request import validate_args
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.cache import clear_challenges, clear_config, clear_standings
from CTFd.constants import RawEnum
from CTFd.models import Configs, Fields, db
from CTFd.schemas.config import ConfigSchema
from CTFd.schemas.fields import FieldSchema
from CTFd.utils import set_config
from CTFd.utils.decorators import admins_only
from CTFd.utils.helpers.models import build_model_filters
configs_namespace = Namespace("configs", description="Endpoint to retrieve Configs")
ConfigModel = sqlalchemy_to_pydantic(Configs)
class ConfigDetailedSuccessResponse(APIDetailedSuccessResponse):
data: ConfigModel
class ConfigListSuccessResponse(APIListSuccessResponse):
data: List[ConfigModel]
configs_namespace.schema_model(
"ConfigDetailedSuccessResponse", ConfigDetailedSuccessResponse.apidoc()
)
configs_namespace.schema_model(
"ConfigListSuccessResponse", ConfigListSuccessResponse.apidoc()
)
@configs_namespace.route("")
class ConfigList(Resource):
@admins_only
@configs_namespace.doc(
description="Endpoint to get Config objects in bulk",
responses={
200: ("Success", "ConfigListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
@validate_args(
{
"key": (str, None),
"value": (str, None),
"q": (str, None),
"field": (RawEnum("ConfigFields", {"key": "key", "value": "value"}), None),
},
location="query",
)
def get(self, query_args):
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
filters = build_model_filters(model=Configs, query=q, field=field)
configs = Configs.query.filter_by(**query_args).filter(*filters).all()
schema = ConfigSchema(many=True)
response = schema.dump(configs)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
@admins_only
@configs_namespace.doc(
description="Endpoint to get create a Config object",
responses={
200: ("Success", "ConfigDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self):
req = request.get_json()
schema = ConfigSchema()
response = schema.load(req)
if response.errors:
return {"success": False, "errors": response.errors}, 400
db.session.add(response.data)
db.session.commit()
response = schema.dump(response.data)
db.session.close()
clear_config()
clear_standings()
clear_challenges()
return {"success": True, "data": response.data}
@admins_only
@configs_namespace.doc(
description="Endpoint to get patch Config objects in bulk",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def patch(self):
req = request.get_json()
schema = ConfigSchema()
for key, value in req.items():
response = schema.load({"key": key, "value": value})
if response.errors:
return {"success": False, "errors": response.errors}, 400
set_config(key=key, value=value)
clear_config()
clear_standings()
clear_challenges()
return {"success": True}
@configs_namespace.route("/<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}

View File

@@ -1,144 +0,0 @@
from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.request import validate_args
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.constants import RawEnum
from CTFd.models import Files, db
from CTFd.schemas.files import FileSchema
from CTFd.utils import uploads
from CTFd.utils.decorators import admins_only
from CTFd.utils.helpers.models import build_model_filters
files_namespace = Namespace("files", description="Endpoint to retrieve Files")
FileModel = sqlalchemy_to_pydantic(Files)
class FileDetailedSuccessResponse(APIDetailedSuccessResponse):
data: FileModel
class FileListSuccessResponse(APIListSuccessResponse):
data: List[FileModel]
files_namespace.schema_model(
"FileDetailedSuccessResponse", FileDetailedSuccessResponse.apidoc()
)
files_namespace.schema_model(
"FileListSuccessResponse", FileListSuccessResponse.apidoc()
)
@files_namespace.route("")
class FilesList(Resource):
@admins_only
@files_namespace.doc(
description="Endpoint to get file objects in bulk",
responses={
200: ("Success", "FileListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
@validate_args(
{
"type": (str, None),
"location": (str, None),
"q": (str, None),
"field": (
RawEnum("FileFields", {"type": "type", "location": "location"}),
None,
),
},
location="query",
)
def get(self, query_args):
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
filters = build_model_filters(model=Files, query=q, field=field)
files = Files.query.filter_by(**query_args).filter(*filters).all()
schema = FileSchema(many=True)
response = schema.dump(files)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
@admins_only
@files_namespace.doc(
description="Endpoint to get file objects in bulk",
responses={
200: ("Success", "FileDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self):
files = request.files.getlist("file")
# challenge_id
# page_id
objs = []
for f in files:
# uploads.upload_file(file=f, chalid=req.get('challenge'))
obj = uploads.upload_file(file=f, **request.form.to_dict())
objs.append(obj)
schema = FileSchema(many=True)
response = schema.dump(objs)
if response.errors:
return {"success": False, "errors": response.errorss}, 400
return {"success": True, "data": response.data}
@files_namespace.route("/<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}

View File

@@ -1,193 +0,0 @@
from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.request import validate_args
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.constants import RawEnum
from CTFd.models import Flags, db
from CTFd.plugins.flags import FLAG_CLASSES, get_flag_class
from CTFd.schemas.flags import FlagSchema
from CTFd.utils.decorators import admins_only
from CTFd.utils.helpers.models import build_model_filters
flags_namespace = Namespace("flags", description="Endpoint to retrieve Flags")
FlagModel = sqlalchemy_to_pydantic(Flags)
class FlagDetailedSuccessResponse(APIDetailedSuccessResponse):
data: FlagModel
class FlagListSuccessResponse(APIListSuccessResponse):
data: List[FlagModel]
flags_namespace.schema_model(
"FlagDetailedSuccessResponse", FlagDetailedSuccessResponse.apidoc()
)
flags_namespace.schema_model(
"FlagListSuccessResponse", FlagListSuccessResponse.apidoc()
)
@flags_namespace.route("")
class FlagList(Resource):
@admins_only
@flags_namespace.doc(
description="Endpoint to list Flag objects in bulk",
responses={
200: ("Success", "FlagListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
@validate_args(
{
"challenge_id": (int, None),
"type": (str, None),
"content": (str, None),
"data": (str, None),
"q": (str, None),
"field": (
RawEnum(
"FlagFields", {"type": "type", "content": "content", "data": "data"}
),
None,
),
},
location="query",
)
def get(self, query_args):
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
filters = build_model_filters(model=Flags, query=q, field=field)
flags = Flags.query.filter_by(**query_args).filter(*filters).all()
schema = FlagSchema(many=True)
response = schema.dump(flags)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
@admins_only
@flags_namespace.doc(
description="Endpoint to create a Flag object",
responses={
200: ("Success", "FlagDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self):
req = request.get_json()
schema = FlagSchema()
response = schema.load(req, session=db.session)
if response.errors:
return {"success": False, "errors": response.errors}, 400
db.session.add(response.data)
db.session.commit()
response = schema.dump(response.data)
db.session.close()
return {"success": True, "data": response.data}
@flags_namespace.route("/types", defaults={"type_name": None})
@flags_namespace.route("/types/<type_name>")
class FlagTypes(Resource):
@admins_only
def get(self, type_name):
if type_name:
flag_class = get_flag_class(type_name)
response = {"name": flag_class.name, "templates": flag_class.templates}
return {"success": True, "data": response}
else:
response = {}
for class_id in FLAG_CLASSES:
flag_class = FLAG_CLASSES.get(class_id)
response[class_id] = {
"name": flag_class.name,
"templates": flag_class.templates,
}
return {"success": True, "data": response}
@flags_namespace.route("/<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()
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

@@ -1,12 +0,0 @@
# This file is no longer used. If you're importing the function from here please update your imports
from CTFd.utils.helpers.models import build_model_filters as _build_model_filters
def build_model_filters(model, query, field):
print("CTFd.api.v1.helpers.models.build_model_filters has been deprecated.")
print("Please switch to using CTFd.utils.helpers.models.build_model_filters")
print(
"This function will raise an exception in a future minor release of CTFd and then be removed in a major release."
)
return _build_model_filters(model, query, field)

View File

@@ -1,60 +0,0 @@
from functools import wraps
from flask import request
from pydantic import ValidationError, create_model
ARG_LOCATIONS = {
"query": lambda: request.args,
"json": lambda: request.get_json(),
"form": lambda: request.form,
"headers": lambda: request.headers,
"cookies": lambda: request.cookies,
}
def validate_args(spec, location):
"""
A rough implementation of webargs using pydantic schemas. You can pass a
pydantic schema as spec or create it on the fly as follows:
@validate_args({"name": (str, None), "id": (int, None)}, location="query")
"""
if isinstance(spec, dict):
spec = create_model("", **spec)
schema = spec.schema()
props = schema.get("properties", {})
required = schema.get("required", [])
for k in props:
if k in required:
props[k]["required"] = True
props[k]["in"] = location
def decorator(func):
# Inject parameters information into the Flask-Restx apidoc attribute.
# Not really a good solution. See https://github.com/CTFd/CTFd/issues/1504
apidoc = getattr(func, "__apidoc__", {"params": {}})
apidoc["params"].update(props)
func.__apidoc__ = apidoc
@wraps(func)
def wrapper(*args, **kwargs):
data = ARG_LOCATIONS[location]()
try:
# Try to load data according to pydantic spec
loaded = spec(**data).dict(exclude_unset=True)
except ValidationError as e:
# Handle reporting errors when invalid
resp = {}
errors = e.errors()
for err in errors:
loc = err["loc"][0]
msg = err["msg"]
resp[loc] = msg
return {"success": False, "errors": resp}, 400
return func(*args, loaded, **kwargs)
return wrapper
return decorator

View File

@@ -1,37 +0,0 @@
from typing import Container, Dict, Type
from pydantic import BaseModel, create_model
from sqlalchemy.inspection import inspect
from sqlalchemy.orm.properties import ColumnProperty
def sqlalchemy_to_pydantic(
db_model: Type, *, include: Dict[str, type] = None, exclude: Container[str] = None
) -> Type[BaseModel]:
"""
Mostly copied from https://github.com/tiangolo/pydantic-sqlalchemy
"""
if exclude is None:
exclude = []
mapper = inspect(db_model)
fields = {}
for attr in mapper.attrs:
if isinstance(attr, ColumnProperty):
if attr.columns:
column = attr.columns[0]
python_type = column.type.python_type
name = attr.key
if name in exclude:
continue
default = None
if column.default is None and not column.nullable:
default = ...
fields[name] = (python_type, default)
if bool(include):
for name, python_type in include.items():
default = None
fields[name] = (python_type, default)
pydantic_model = create_model(
db_model.__name__, **fields # type: ignore
)
return pydantic_model

View File

@@ -1,216 +0,0 @@
from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.request import validate_args
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.constants import RawEnum
from CTFd.models import Hints, HintUnlocks, db
from CTFd.schemas.hints import HintSchema
from CTFd.utils.decorators import admins_only, authed_only, during_ctf_time_only
from CTFd.utils.helpers.models import build_model_filters
from CTFd.utils.user import get_current_user, is_admin
hints_namespace = Namespace("hints", description="Endpoint to retrieve Hints")
HintModel = sqlalchemy_to_pydantic(Hints)
class HintDetailedSuccessResponse(APIDetailedSuccessResponse):
data: HintModel
class HintListSuccessResponse(APIListSuccessResponse):
data: List[HintModel]
hints_namespace.schema_model(
"HintDetailedSuccessResponse", HintDetailedSuccessResponse.apidoc()
)
hints_namespace.schema_model(
"HintListSuccessResponse", HintListSuccessResponse.apidoc()
)
@hints_namespace.route("")
class HintList(Resource):
@admins_only
@hints_namespace.doc(
description="Endpoint to list Hint objects in bulk",
responses={
200: ("Success", "HintListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
@validate_args(
{
"type": (str, None),
"challenge_id": (int, None),
"content": (str, None),
"cost": (int, None),
"q": (str, None),
"field": (
RawEnum("HintFields", {"type": "type", "content": "content"}),
None,
),
},
location="query",
)
def get(self, query_args):
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
filters = build_model_filters(model=Hints, query=q, field=field)
hints = Hints.query.filter_by(**query_args).filter(*filters).all()
response = HintSchema(many=True, view="locked").dump(hints)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
@admins_only
@hints_namespace.doc(
description="Endpoint to create a Hint object",
responses={
200: ("Success", "HintDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self):
req = request.get_json()
schema = HintSchema(view="admin")
response = schema.load(req, session=db.session)
if response.errors:
return {"success": False, "errors": response.errors}, 400
db.session.add(response.data)
db.session.commit()
response = schema.dump(response.data)
return {"success": True, "data": response.data}
@hints_namespace.route("/<hint_id>")
class Hint(Resource):
@during_ctf_time_only
@authed_only
@hints_namespace.doc(
description="Endpoint to get a specific Hint object",
responses={
200: ("Success", "HintDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self, hint_id):
user = get_current_user()
hint = Hints.query.filter_by(id=hint_id).first_or_404()
if hint.requirements:
requirements = hint.requirements.get("prerequisites", [])
# Get the IDs of all hints that the user has unlocked
all_unlocks = HintUnlocks.query.filter_by(account_id=user.account_id).all()
unlock_ids = {unlock.target for unlock in all_unlocks}
# Get the IDs of all free hints
free_hints = Hints.query.filter_by(cost=0).all()
free_ids = {h.id for h in free_hints}
# Add free hints to unlocked IDs
unlock_ids.update(free_ids)
# Filter out hint IDs that don't exist
all_hint_ids = {h.id for h in Hints.query.with_entities(Hints.id).all()}
prereqs = set(requirements).intersection(all_hint_ids)
# If the user has the necessary unlocks or is admin we should allow them to view
if unlock_ids >= prereqs or is_admin():
pass
else:
return (
{
"success": False,
"errors": {
"requirements": [
"You must unlock other hints before accessing this hint"
]
},
},
403,
)
view = "unlocked"
if hint.cost:
view = "locked"
unlocked = HintUnlocks.query.filter_by(
account_id=user.account_id, target=hint.id
).first()
if unlocked:
view = "unlocked"
if is_admin():
if request.args.get("preview", False):
view = "admin"
response = HintSchema(view=view).dump(hint)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
@admins_only
@hints_namespace.doc(
description="Endpoint to edit a specific Hint object",
responses={
200: ("Success", "HintDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def patch(self, hint_id):
hint = Hints.query.filter_by(id=hint_id).first_or_404()
req = request.get_json()
schema = HintSchema(view="admin")
response = schema.load(req, instance=hint, partial=True, session=db.session)
if response.errors:
return {"success": False, "errors": response.errors}, 400
db.session.add(response.data)
db.session.commit()
response = schema.dump(response.data)
return {"success": True, "data": response.data}
@admins_only
@hints_namespace.doc(
description="Endpoint to delete a specific Tag object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, hint_id):
hint = Hints.query.filter_by(id=hint_id).first_or_404()
db.session.delete(hint)
db.session.commit()
db.session.close()
return {"success": True}

View File

@@ -1,189 +0,0 @@
from typing import List
from flask import current_app, make_response, request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.request import validate_args
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.constants import RawEnum
from CTFd.models import Notifications, db
from CTFd.schemas.notifications import NotificationSchema
from CTFd.utils.decorators import admins_only
from CTFd.utils.helpers.models import build_model_filters
notifications_namespace = Namespace(
"notifications", description="Endpoint to retrieve Notifications"
)
NotificationModel = sqlalchemy_to_pydantic(Notifications)
TransientNotificationModel = sqlalchemy_to_pydantic(Notifications, exclude=["id"])
class NotificationDetailedSuccessResponse(APIDetailedSuccessResponse):
data: NotificationModel
class NotificationListSuccessResponse(APIListSuccessResponse):
data: List[NotificationModel]
notifications_namespace.schema_model(
"NotificationDetailedSuccessResponse", NotificationDetailedSuccessResponse.apidoc()
)
notifications_namespace.schema_model(
"NotificationListSuccessResponse", NotificationListSuccessResponse.apidoc()
)
@notifications_namespace.route("")
class NotificantionList(Resource):
@notifications_namespace.doc(
description="Endpoint to get notification objects in bulk",
responses={
200: ("Success", "NotificationListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
@validate_args(
{
"title": (str, None),
"content": (str, None),
"user_id": (int, None),
"team_id": (int, None),
"q": (str, None),
"field": (
RawEnum("NotificationFields", {"title": "title", "content": "content"}),
None,
),
"since_id": (int, None),
},
location="query",
)
def get(self, query_args):
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
filters = build_model_filters(model=Notifications, query=q, field=field)
since_id = query_args.pop("since_id", None)
if since_id:
filters.append((Notifications.id > since_id))
notifications = (
Notifications.query.filter_by(**query_args).filter(*filters).all()
)
schema = NotificationSchema(many=True)
result = schema.dump(notifications)
if result.errors:
return {"success": False, "errors": result.errors}, 400
return {"success": True, "data": result.data}
@notifications_namespace.doc(
description="Endpoint to get statistics for notification objects in bulk",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
@validate_args(
{
"title": (str, None),
"content": (str, None),
"user_id": (int, None),
"team_id": (int, None),
"q": (str, None),
"field": (
RawEnum("NotificationFields", {"title": "title", "content": "content"}),
None,
),
"since_id": (int, None),
},
location="query",
)
def head(self, query_args):
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
filters = build_model_filters(model=Notifications, query=q, field=field)
since_id = query_args.pop("since_id", None)
if since_id:
filters.append((Notifications.id > since_id))
notification_count = (
Notifications.query.filter_by(**query_args).filter(*filters).count()
)
response = make_response()
response.headers["Result-Count"] = notification_count
return response
@admins_only
@notifications_namespace.doc(
description="Endpoint to create a notification object",
responses={
200: ("Success", "NotificationDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self):
req = request.get_json()
schema = NotificationSchema()
result = schema.load(req)
if result.errors:
return {"success": False, "errors": result.errors}, 400
db.session.add(result.data)
db.session.commit()
response = schema.dump(result.data)
# Grab additional settings
notif_type = req.get("type", "alert")
notif_sound = req.get("sound", True)
response.data["type"] = notif_type
response.data["sound"] = notif_sound
current_app.events_manager.publish(data=response.data, type="notification")
return {"success": True, "data": response.data}
@notifications_namespace.route("/<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}

View File

@@ -1,177 +0,0 @@
from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.request import validate_args
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.cache import clear_pages
from CTFd.constants import RawEnum
from CTFd.models import Pages, db
from CTFd.schemas.pages import PageSchema
from CTFd.utils.decorators import admins_only
from CTFd.utils.helpers.models import build_model_filters
pages_namespace = Namespace("pages", description="Endpoint to retrieve Pages")
PageModel = sqlalchemy_to_pydantic(Pages)
TransientPageModel = sqlalchemy_to_pydantic(Pages, exclude=["id"])
class PageDetailedSuccessResponse(APIDetailedSuccessResponse):
data: PageModel
class PageListSuccessResponse(APIListSuccessResponse):
data: List[PageModel]
pages_namespace.schema_model(
"PageDetailedSuccessResponse", PageDetailedSuccessResponse.apidoc()
)
pages_namespace.schema_model(
"PageListSuccessResponse", PageListSuccessResponse.apidoc()
)
@pages_namespace.route("")
@pages_namespace.doc(
responses={200: "Success", 400: "An error occured processing your data"}
)
class PageList(Resource):
@admins_only
@pages_namespace.doc(
description="Endpoint to get page objects in bulk",
responses={
200: ("Success", "PageListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
@validate_args(
{
"id": (int, None),
"title": (str, None),
"route": (str, None),
"draft": (bool, None),
"hidden": (bool, None),
"auth_required": (bool, None),
"q": (str, None),
"field": (
RawEnum(
"PageFields",
{"title": "title", "route": "route", "content": "content"},
),
None,
),
},
location="query",
)
def get(self, query_args):
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
filters = build_model_filters(model=Pages, query=q, field=field)
pages = Pages.query.filter_by(**query_args).filter(*filters).all()
schema = PageSchema(exclude=["content"], many=True)
response = schema.dump(pages)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
@admins_only
@pages_namespace.doc(
description="Endpoint to create a page object",
responses={
200: ("Success", "PageDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
@validate_args(TransientPageModel, location="json")
def post(self, json_args):
req = json_args
schema = PageSchema()
response = schema.load(req)
if response.errors:
return {"success": False, "errors": response.errors}, 400
db.session.add(response.data)
db.session.commit()
response = schema.dump(response.data)
db.session.close()
clear_pages()
return {"success": True, "data": response.data}
@pages_namespace.route("/<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

@@ -1,105 +0,0 @@
from typing import Any, Dict, List, Optional
from pydantic import BaseModel
class APISimpleSuccessResponse(BaseModel):
success: bool = True
class APIDetailedSuccessResponse(APISimpleSuccessResponse):
data: Optional[Any]
@classmethod
def apidoc(cls):
"""
Helper to inline references from the generated schema
"""
schema = cls.schema()
try:
key = schema["properties"]["data"]["$ref"]
ref = key.split("/").pop()
definition = schema["definitions"][ref]
schema["properties"]["data"] = definition
del schema["definitions"][ref]
if bool(schema["definitions"]) is False:
del schema["definitions"]
except KeyError:
pass
return schema
class APIListSuccessResponse(APIDetailedSuccessResponse):
data: Optional[List[Any]]
@classmethod
def apidoc(cls):
"""
Helper to inline references from the generated schema
"""
schema = cls.schema()
try:
key = schema["properties"]["data"]["items"]["$ref"]
ref = key.split("/").pop()
definition = schema["definitions"][ref]
schema["properties"]["data"]["items"] = definition
del schema["definitions"][ref]
if bool(schema["definitions"]) is False:
del schema["definitions"]
except KeyError:
pass
return schema
class PaginatedAPIListSuccessResponse(APIListSuccessResponse):
meta: Dict[str, Any]
@classmethod
def apidoc(cls):
"""
Helper to inline references from the generated schema
"""
schema = cls.schema()
schema["properties"]["meta"] = {
"title": "Meta",
"type": "object",
"properties": {
"pagination": {
"title": "Pagination",
"type": "object",
"properties": {
"page": {"title": "Page", "type": "integer"},
"next": {"title": "Next", "type": "integer"},
"prev": {"title": "Prev", "type": "integer"},
"pages": {"title": "Pages", "type": "integer"},
"per_page": {"title": "Per Page", "type": "integer"},
"total": {"title": "Total", "type": "integer"},
},
"required": ["page", "next", "prev", "pages", "per_page", "total"],
}
},
"required": ["pagination"],
}
try:
key = schema["properties"]["data"]["items"]["$ref"]
ref = key.split("/").pop()
definition = schema["definitions"][ref]
schema["properties"]["data"]["items"] = definition
del schema["definitions"][ref]
if bool(schema["definitions"]) is False:
del schema["definitions"]
except KeyError:
pass
return schema
class APISimpleErrorResponse(BaseModel):
success: bool = False
errors: Optional[List[str]]

View File

@@ -1,143 +0,0 @@
from collections import defaultdict
from flask_restx import Namespace, Resource
from sqlalchemy import select
from CTFd.cache import cache, make_cache_key
from CTFd.models import Awards, Solves, Users, db
from CTFd.utils import get_config
from CTFd.utils.dates import isoformat, unix_time_to_utc
from CTFd.utils.decorators.visibility import (
check_account_visibility,
check_score_visibility,
)
from CTFd.utils.modes import TEAMS_MODE, generate_account_url, get_mode_as_word
from CTFd.utils.scores import get_standings, get_user_standings
scoreboard_namespace = Namespace(
"scoreboard", description="Endpoint to retrieve scores"
)
@scoreboard_namespace.route("")
class ScoreboardList(Resource):
@check_account_visibility
@check_score_visibility
@cache.cached(timeout=60, key_prefix=make_cache_key)
def get(self):
standings = get_standings()
response = []
mode = get_config("user_mode")
account_type = get_mode_as_word()
if mode == TEAMS_MODE:
r = db.session.execute(
select(
[
Users.id,
Users.name,
Users.oauth_id,
Users.team_id,
Users.hidden,
Users.banned,
]
).where(Users.team_id.isnot(None))
)
users = r.fetchall()
membership = defaultdict(dict)
for u in users:
if u.hidden is False and u.banned is False:
membership[u.team_id][u.id] = {
"id": u.id,
"oauth_id": u.oauth_id,
"name": u.name,
"score": 0,
}
# Get user_standings as a dict so that we can more quickly get member scores
user_standings = get_user_standings()
for u in user_standings:
membership[u.team_id][u.user_id]["score"] = int(u.score)
for i, x in enumerate(standings):
entry = {
"pos": i + 1,
"account_id": x.account_id,
"account_url": generate_account_url(account_id=x.account_id),
"account_type": account_type,
"oauth_id": x.oauth_id,
"name": x.name,
"score": int(x.score),
}
if mode == TEAMS_MODE:
entry["members"] = list(membership[x.account_id].values())
response.append(entry)
return {"success": True, "data": response}
@scoreboard_namespace.route("/top/<int:count>")
@scoreboard_namespace.param("count", "How many top teams to return")
class ScoreboardDetail(Resource):
@check_account_visibility
@check_score_visibility
@cache.cached(timeout=60, key_prefix=make_cache_key)
def get(self, count):
response = {}
standings = get_standings(count=count)
team_ids = [team.account_id for team in standings]
solves = Solves.query.filter(Solves.account_id.in_(team_ids))
awards = Awards.query.filter(Awards.account_id.in_(team_ids))
freeze = get_config("freeze")
if freeze:
solves = solves.filter(Solves.date < unix_time_to_utc(freeze))
awards = awards.filter(Awards.date < unix_time_to_utc(freeze))
solves = solves.all()
awards = awards.all()
# Build a mapping of accounts to their solves and awards
solves_mapper = defaultdict(list)
for solve in solves:
solves_mapper[solve.account_id].append(
{
"challenge_id": solve.challenge_id,
"account_id": solve.account_id,
"team_id": solve.team_id,
"user_id": solve.user_id,
"value": solve.challenge.value,
"date": isoformat(solve.date),
}
)
for award in awards:
solves_mapper[award.account_id].append(
{
"challenge_id": None,
"account_id": award.account_id,
"team_id": award.team_id,
"user_id": award.user_id,
"value": award.value,
"date": isoformat(award.date),
}
)
# Sort all solves by date
for team_id in solves_mapper:
solves_mapper[team_id] = sorted(
solves_mapper[team_id], key=lambda k: k["date"]
)
for i, _team in enumerate(team_ids):
response[i + 1] = {
"id": standings[i].account_id,
"name": standings[i].name,
"solves": solves_mapper.get(standings[i].account_id, []),
}
return {"success": True, "data": response}

View File

@@ -1,12 +0,0 @@
from flask_restx import Namespace
statistics_namespace = Namespace(
"statistics", description="Endpoint to retrieve Statistics"
)
# isort:imports-firstparty
from CTFd.api.v1.statistics import challenges # noqa: F401,I001
from CTFd.api.v1.statistics import scores # noqa: F401
from CTFd.api.v1.statistics import submissions # noqa: F401
from CTFd.api.v1.statistics import teams # noqa: F401
from CTFd.api.v1.statistics import users # noqa: F401

View File

@@ -1,120 +0,0 @@
from flask_restx import Resource
from sqlalchemy import func
from sqlalchemy.sql import and_
from CTFd.api.v1.statistics import statistics_namespace
from CTFd.models import Challenges, Solves, db
from CTFd.utils.decorators import admins_only
from CTFd.utils.modes import get_model
@statistics_namespace.route("/challenges/<column>")
class ChallengePropertyCounts(Resource):
@admins_only
def get(self, column):
if column in Challenges.__table__.columns.keys():
prop = getattr(Challenges, column)
data = (
Challenges.query.with_entities(prop, func.count(prop))
.group_by(prop)
.all()
)
return {"success": True, "data": dict(data)}
else:
response = {"message": "That could not be found"}, 404
return response
@statistics_namespace.route("/challenges/solves")
class ChallengeSolveStatistics(Resource):
@admins_only
def get(self):
chals = (
Challenges.query.filter(
and_(Challenges.state != "hidden", Challenges.state != "locked")
)
.order_by(Challenges.value)
.all()
)
Model = get_model()
solves_sub = (
db.session.query(
Solves.challenge_id, db.func.count(Solves.challenge_id).label("solves")
)
.join(Model, Solves.account_id == Model.id)
.filter(Model.banned == False, Model.hidden == False)
.group_by(Solves.challenge_id)
.subquery()
)
solves = (
db.session.query(
solves_sub.columns.challenge_id,
solves_sub.columns.solves,
Challenges.name,
)
.join(Challenges, solves_sub.columns.challenge_id == Challenges.id)
.all()
)
response = []
has_solves = []
for challenge_id, count, name in solves:
challenge = {"id": challenge_id, "name": name, "solves": count}
response.append(challenge)
has_solves.append(challenge_id)
for c in chals:
if c.id not in has_solves:
challenge = {"id": c.id, "name": c.name, "solves": 0}
response.append(challenge)
db.session.close()
return {"success": True, "data": response}
@statistics_namespace.route("/challenges/solves/percentages")
class ChallengeSolvePercentages(Resource):
@admins_only
def get(self):
challenges = (
Challenges.query.add_columns("id", "name", "state", "max_attempts")
.order_by(Challenges.value)
.all()
)
Model = get_model()
teams_with_points = (
db.session.query(Solves.account_id)
.join(Model)
.filter(Model.banned == False, Model.hidden == False)
.group_by(Solves.account_id)
.count()
)
percentage_data = []
for challenge in challenges:
solve_count = (
Solves.query.join(Model, Solves.account_id == Model.id)
.filter(
Solves.challenge_id == challenge.id,
Model.banned == False,
Model.hidden == False,
)
.count()
)
if teams_with_points > 0:
percentage = float(solve_count) / float(teams_with_points)
else:
percentage = 0.0
percentage_data.append(
{"id": challenge.id, "name": challenge.name, "percentage": percentage}
)
response = sorted(percentage_data, key=lambda x: x["percentage"], reverse=True)
return {"success": True, "data": response}

View File

@@ -1,43 +0,0 @@
from collections import defaultdict
from flask_restx import Resource
from CTFd.api.v1.statistics import statistics_namespace
from CTFd.models import Challenges, db
from CTFd.utils.decorators import admins_only
from CTFd.utils.scores import get_standings
@statistics_namespace.route("/scores/distribution")
class ScoresDistribution(Resource):
@admins_only
def get(self):
challenge_count = Challenges.query.count() or 1
total_points = (
Challenges.query.with_entities(db.func.sum(Challenges.value).label("sum"))
.filter_by(state="visible")
.first()
.sum
) or 0
# Convert Decimal() to int in some database backends for Python 2
total_points = int(total_points)
# Divide score by challenges to get brackets with explicit floor division
bracket_size = total_points // challenge_count
# Get standings
standings = get_standings(admin=True)
# Iterate over standings and increment the count for each bracket for each standing within that bracket
bottom, top = 0, bracket_size
count = 1
brackets = defaultdict(lambda: 0)
for t in reversed(standings):
if ((t.score >= bottom) and (t.score <= top)) or t.score <= 0:
brackets[top] += 1
else:
count += 1
bottom, top = (bracket_size, (bracket_size * count))
brackets[top] += 1
return {"success": True, "data": {"brackets": brackets}}

View File

@@ -1,23 +0,0 @@
from flask_restx import Resource
from sqlalchemy import func
from CTFd.api.v1.statistics import statistics_namespace
from CTFd.models import Submissions
from CTFd.utils.decorators import admins_only
@statistics_namespace.route("/submissions/<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

@@ -1,14 +0,0 @@
from flask_restx import Resource
from CTFd.api.v1.statistics import statistics_namespace
from CTFd.models import Teams
from CTFd.utils.decorators import admins_only
@statistics_namespace.route("/teams")
class TeamStatistics(Resource):
@admins_only
def get(self):
registered = Teams.query.count()
data = {"registered": registered}
return {"success": True, "data": data}

View File

@@ -1,30 +0,0 @@
from flask_restx import Resource
from sqlalchemy import func
from CTFd.api.v1.statistics import statistics_namespace
from CTFd.models import Users
from CTFd.utils.decorators import admins_only
@statistics_namespace.route("/users")
class UserStatistics(Resource):
@admins_only
def get(self):
registered = Users.query.count()
confirmed = Users.query.filter_by(verified=True).count()
data = {"registered": registered, "confirmed": confirmed}
return {"success": True, "data": data}
@statistics_namespace.route("/users/<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

View File

@@ -1,195 +0,0 @@
from typing import List
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.request import validate_args
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import (
APIDetailedSuccessResponse,
PaginatedAPIListSuccessResponse,
)
from CTFd.cache import clear_challenges, clear_standings
from CTFd.constants import RawEnum
from CTFd.models import Submissions, db
from CTFd.schemas.submissions import SubmissionSchema
from CTFd.utils.decorators import admins_only
from CTFd.utils.helpers.models import build_model_filters
submissions_namespace = Namespace(
"submissions", description="Endpoint to retrieve Submission"
)
SubmissionModel = sqlalchemy_to_pydantic(Submissions)
TransientSubmissionModel = sqlalchemy_to_pydantic(Submissions, exclude=["id"])
class SubmissionDetailedSuccessResponse(APIDetailedSuccessResponse):
data: SubmissionModel
class SubmissionListSuccessResponse(PaginatedAPIListSuccessResponse):
data: List[SubmissionModel]
submissions_namespace.schema_model(
"SubmissionDetailedSuccessResponse", SubmissionDetailedSuccessResponse.apidoc()
)
submissions_namespace.schema_model(
"SubmissionListSuccessResponse", SubmissionListSuccessResponse.apidoc()
)
@submissions_namespace.route("")
class SubmissionsList(Resource):
@admins_only
@submissions_namespace.doc(
description="Endpoint to get submission objects in bulk",
responses={
200: ("Success", "SubmissionListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
@validate_args(
{
"challenge_id": (int, None),
"user_id": (int, None),
"team_id": (int, None),
"ip": (str, None),
"provided": (str, None),
"type": (str, None),
"q": (str, None),
"field": (
RawEnum(
"SubmissionFields",
{
"challenge_id": "challenge_id",
"user_id": "user_id",
"team_id": "team_id",
"ip": "ip",
"provided": "provided",
"type": "type",
},
),
None,
),
},
location="query",
)
def get(self, query_args):
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
filters = build_model_filters(model=Submissions, query=q, field=field)
args = query_args
schema = SubmissionSchema(many=True)
submissions = (
Submissions.query.filter_by(**args)
.filter(*filters)
.paginate(max_per_page=100)
)
response = schema.dump(submissions.items)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {
"meta": {
"pagination": {
"page": submissions.page,
"next": submissions.next_num,
"prev": submissions.prev_num,
"pages": submissions.pages,
"per_page": submissions.per_page,
"total": submissions.total,
}
},
"success": True,
"data": response.data,
}
@admins_only
@submissions_namespace.doc(
description="Endpoint to create a submission object. Users should interact with the attempt endpoint to submit flags.",
responses={
200: ("Success", "SubmissionListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
@validate_args(TransientSubmissionModel, location="json")
def post(self, json_args):
req = json_args
Model = Submissions.get_child(type=req.get("type"))
schema = SubmissionSchema(instance=Model())
response = schema.load(req)
if response.errors:
return {"success": False, "errors": response.errors}, 400
db.session.add(response.data)
db.session.commit()
response = schema.dump(response.data)
db.session.close()
# Delete standings cache
clear_standings()
# Delete challenges cache
clear_challenges()
return {"success": True, "data": response.data}
@submissions_namespace.route("/<submission_id>")
@submissions_namespace.param("submission_id", "A Submission ID")
class Submission(Resource):
@admins_only
@submissions_namespace.doc(
description="Endpoint to get submission objects in bulk",
responses={
200: ("Success", "SubmissionDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self, submission_id):
submission = Submissions.query.filter_by(id=submission_id).first_or_404()
schema = SubmissionSchema()
response = schema.dump(submission)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
@admins_only
@submissions_namespace.doc(
description="Endpoint to get submission objects in bulk",
responses={
200: ("Success", "APISimpleSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def delete(self, submission_id):
submission = Submissions.query.filter_by(id=submission_id).first_or_404()
db.session.delete(submission)
db.session.commit()
db.session.close()
# Delete standings cache
clear_standings()
clear_challenges()
return {"success": True}

View File

@@ -1,166 +0,0 @@
from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.request import validate_args
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.constants import RawEnum
from CTFd.models import Tags, db
from CTFd.schemas.tags import TagSchema
from CTFd.utils.decorators import admins_only
from CTFd.utils.helpers.models import build_model_filters
tags_namespace = Namespace("tags", description="Endpoint to retrieve Tags")
TagModel = sqlalchemy_to_pydantic(Tags)
class TagDetailedSuccessResponse(APIDetailedSuccessResponse):
data: TagModel
class TagListSuccessResponse(APIListSuccessResponse):
data: List[TagModel]
tags_namespace.schema_model(
"TagDetailedSuccessResponse", TagDetailedSuccessResponse.apidoc()
)
tags_namespace.schema_model("TagListSuccessResponse", TagListSuccessResponse.apidoc())
@tags_namespace.route("")
class TagList(Resource):
@admins_only
@tags_namespace.doc(
description="Endpoint to list Tag objects in bulk",
responses={
200: ("Success", "TagListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
@validate_args(
{
"challenge_id": (int, None),
"value": (str, None),
"q": (str, None),
"field": (
RawEnum(
"TagFields", {"challenge_id": "challenge_id", "value": "value"}
),
None,
),
},
location="query",
)
def get(self, query_args):
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
filters = build_model_filters(model=Tags, query=q, field=field)
tags = Tags.query.filter_by(**query_args).filter(*filters).all()
schema = TagSchema(many=True)
response = schema.dump(tags)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
@admins_only
@tags_namespace.doc(
description="Endpoint to create a Tag object",
responses={
200: ("Success", "TagDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self):
req = request.get_json()
schema = TagSchema()
response = schema.load(req, session=db.session)
if response.errors:
return {"success": False, "errors": response.errors}, 400
db.session.add(response.data)
db.session.commit()
response = schema.dump(response.data)
db.session.close()
return {"success": True, "data": response.data}
@tags_namespace.route("/<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}

View File

@@ -1,657 +0,0 @@
import copy
from typing import List
from flask import abort, request, session
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.request import validate_args
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import (
APIDetailedSuccessResponse,
PaginatedAPIListSuccessResponse,
)
from CTFd.cache import (
clear_challenges,
clear_standings,
clear_team_session,
clear_user_session,
)
from CTFd.constants import RawEnum
from CTFd.models import Awards, Submissions, Teams, Unlocks, Users, db
from CTFd.schemas.awards import AwardSchema
from CTFd.schemas.submissions import SubmissionSchema
from CTFd.schemas.teams import TeamSchema
from CTFd.utils import get_config
from CTFd.utils.decorators import admins_only, authed_only, require_team
from CTFd.utils.decorators.modes import require_team_mode
from CTFd.utils.decorators.visibility import (
check_account_visibility,
check_score_visibility,
)
from CTFd.utils.helpers.models import build_model_filters
from CTFd.utils.user import get_current_team, get_current_user_type, is_admin
teams_namespace = Namespace("teams", description="Endpoint to retrieve Teams")
TeamModel = sqlalchemy_to_pydantic(Teams)
TransientTeamModel = sqlalchemy_to_pydantic(Teams, exclude=["id"])
class TeamDetailedSuccessResponse(APIDetailedSuccessResponse):
data: TeamModel
class TeamListSuccessResponse(PaginatedAPIListSuccessResponse):
data: List[TeamModel]
teams_namespace.schema_model(
"TeamDetailedSuccessResponse", TeamDetailedSuccessResponse.apidoc()
)
teams_namespace.schema_model(
"TeamListSuccessResponse", TeamListSuccessResponse.apidoc()
)
@teams_namespace.route("")
class TeamList(Resource):
method_decorators = [require_team_mode]
@check_account_visibility
@teams_namespace.doc(
description="Endpoint to get Team objects in bulk",
responses={
200: ("Success", "TeamListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
@validate_args(
{
"affiliation": (str, None),
"country": (str, None),
"bracket": (str, None),
"q": (str, None),
"field": (
RawEnum(
"TeamFields",
{
"name": "name",
"website": "website",
"country": "country",
"bracket": "bracket",
"affiliation": "affiliation",
},
),
None,
),
},
location="query",
)
def get(self, query_args):
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
filters = build_model_filters(model=Teams, query=q, field=field)
if is_admin() and request.args.get("view") == "admin":
teams = (
Teams.query.filter_by(**query_args)
.filter(*filters)
.paginate(per_page=50, max_per_page=100)
)
else:
teams = (
Teams.query.filter_by(hidden=False, banned=False, **query_args)
.filter(*filters)
.paginate(per_page=50, max_per_page=100)
)
user_type = get_current_user_type(fallback="user")
view = copy.deepcopy(TeamSchema.views.get(user_type))
view.remove("members")
response = TeamSchema(view=view, many=True).dump(teams.items)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {
"meta": {
"pagination": {
"page": teams.page,
"next": teams.next_num,
"prev": teams.prev_num,
"pages": teams.pages,
"per_page": teams.per_page,
"total": teams.total,
}
},
"success": True,
"data": response.data,
}
@admins_only
@teams_namespace.doc(
description="Endpoint to create a Team object",
responses={
200: ("Success", "TeamDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self):
req = request.get_json()
user_type = get_current_user_type()
view = TeamSchema.views.get(user_type)
schema = TeamSchema(view=view)
response = schema.load(req)
if response.errors:
return {"success": False, "errors": response.errors}, 400
db.session.add(response.data)
db.session.commit()
response = schema.dump(response.data)
db.session.close()
clear_standings()
clear_challenges()
return {"success": True, "data": response.data}
@teams_namespace.route("/<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
response.data["place"] = team.place
response.data["score"] = team.score
return {"success": True, "data": response.data}
@authed_only
@require_team
@teams_namespace.doc(
description="Endpoint to edit the current user's Team object",
responses={
200: ("Success", "TeamDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def patch(self):
team = get_current_team()
if team.captain_id != session["id"]:
return (
{
"success": False,
"errors": {"": ["Only team captains can edit team information"]},
},
403,
)
data = request.get_json()
response = TeamSchema(view="self", instance=team, partial=True).load(data)
if response.errors:
return {"success": False, "errors": response.errors}, 400
db.session.commit()
clear_team_session(team_id=team.id)
response = TeamSchema("self").dump(response.data)
db.session.close()
return {"success": True, "data": response.data}
@authed_only
@require_team
@teams_namespace.doc(
description="Endpoint to disband your current team. Can only be used if the team has performed no actions in the CTF.",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self):
team_disbanding = get_config("team_disbanding", default="inactive_only")
if team_disbanding == "disabled":
return (
{
"success": False,
"errors": {"": ["Team disbanding is currently disabled"]},
},
403,
)
team = get_current_team()
if team.captain_id != session["id"]:
return (
{
"success": False,
"errors": {"": ["Only team captains can disband their team"]},
},
403,
)
# The team must not have performed any actions in the CTF
performed_actions = any(
[
team.solves != [],
team.fails != [],
team.awards != [],
Submissions.query.filter_by(team_id=team.id).all() != [],
Unlocks.query.filter_by(team_id=team.id).all() != [],
]
)
if performed_actions:
return (
{
"success": False,
"errors": {
"": [
"You cannot disband your team as it has participated in the event. "
"Please contact an admin to disband your team or remove a member."
]
},
},
403,
)
for member in team.members:
member.team_id = None
clear_user_session(user_id=member.id)
db.session.delete(team)
db.session.commit()
clear_team_session(team_id=team.id)
clear_standings()
clear_challenges()
db.session.close()
return {"success": True}
@teams_namespace.route("/me/members")
class TeamPrivateMembers(Resource):
method_decorators = [require_team_mode]
@authed_only
@require_team
def post(self):
team = get_current_team()
if team.captain_id != session["id"]:
return (
{
"success": False,
"errors": {"": ["Only team captains can generate invite codes"]},
},
403,
)
invite_code = team.get_invite_code()
response = {"code": invite_code}
return {"success": True, "data": response}
@teams_namespace.route("/<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}}

View File

@@ -1,152 +0,0 @@
import datetime
from typing import List
from flask import request, session
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.models import Tokens, db
from CTFd.schemas.tokens import TokenSchema
from CTFd.utils.decorators import authed_only, require_verified_emails
from CTFd.utils.security.auth import generate_user_token
from CTFd.utils.user import get_current_user, get_current_user_type, is_admin
tokens_namespace = Namespace("tokens", description="Endpoint to retrieve Tokens")
TokenModel = sqlalchemy_to_pydantic(Tokens)
ValuelessTokenModel = sqlalchemy_to_pydantic(Tokens, exclude=["value"])
class TokenDetailedSuccessResponse(APIDetailedSuccessResponse):
data: TokenModel
class ValuelessTokenDetailedSuccessResponse(APIDetailedSuccessResponse):
data: ValuelessTokenModel
class TokenListSuccessResponse(APIListSuccessResponse):
data: List[TokenModel]
tokens_namespace.schema_model(
"TokenDetailedSuccessResponse", TokenDetailedSuccessResponse.apidoc()
)
tokens_namespace.schema_model(
"ValuelessTokenDetailedSuccessResponse",
ValuelessTokenDetailedSuccessResponse.apidoc(),
)
tokens_namespace.schema_model(
"TokenListSuccessResponse", TokenListSuccessResponse.apidoc()
)
@tokens_namespace.route("")
class TokenList(Resource):
@require_verified_emails
@authed_only
@tokens_namespace.doc(
description="Endpoint to get token objects in bulk",
responses={
200: ("Success", "TokenListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self):
user = get_current_user()
tokens = Tokens.query.filter_by(user_id=user.id)
response = TokenSchema(view=["id", "type", "expiration"], many=True).dump(
tokens
)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
@require_verified_emails
@authed_only
@tokens_namespace.doc(
description="Endpoint to create a token object",
responses={
200: ("Success", "TokenDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self):
req = request.get_json()
expiration = req.get("expiration")
if expiration:
expiration = datetime.datetime.strptime(expiration, "%Y-%m-%d")
user = get_current_user()
token = generate_user_token(user, expiration=expiration)
# Explicitly use admin view so that user's can see the value of their token
schema = TokenSchema(view="admin")
response = schema.dump(token)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
@tokens_namespace.route("/<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}

View File

@@ -1,177 +0,0 @@
from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.request import validate_args
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.constants import RawEnum
from CTFd.models import ChallengeTopics, Topics, db
from CTFd.schemas.topics import ChallengeTopicSchema, TopicSchema
from CTFd.utils.decorators import admins_only
from CTFd.utils.helpers.models import build_model_filters
topics_namespace = Namespace("topics", description="Endpoint to retrieve Topics")
TopicModel = sqlalchemy_to_pydantic(Topics)
class TopicDetailedSuccessResponse(APIDetailedSuccessResponse):
data: TopicModel
class TopicListSuccessResponse(APIListSuccessResponse):
data: List[TopicModel]
topics_namespace.schema_model(
"TopicDetailedSuccessResponse", TopicDetailedSuccessResponse.apidoc()
)
topics_namespace.schema_model(
"TopicListSuccessResponse", TopicListSuccessResponse.apidoc()
)
@topics_namespace.route("")
class TopicList(Resource):
@admins_only
@topics_namespace.doc(
description="Endpoint to list Topic objects in bulk",
responses={
200: ("Success", "TopicListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
@validate_args(
{
"value": (str, None),
"q": (str, None),
"field": (RawEnum("TopicFields", {"value": "value"}), None,),
},
location="query",
)
def get(self, query_args):
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
filters = build_model_filters(model=Topics, query=q, field=field)
topics = Topics.query.filter_by(**query_args).filter(*filters).all()
schema = TopicSchema(many=True)
response = schema.dump(topics)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
@admins_only
@topics_namespace.doc(
description="Endpoint to create a Topic object",
responses={
200: ("Success", "TopicDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self):
req = request.get_json()
value = req.get("value")
if value:
topic = Topics.query.filter_by(value=value).first()
if topic is None:
schema = TopicSchema()
response = schema.load(req, session=db.session)
if response.errors:
return {"success": False, "errors": response.errors}, 400
topic = response.data
db.session.add(topic)
db.session.commit()
else:
topic_id = req.get("topic_id")
topic = Topics.query.filter_by(id=topic_id).first_or_404()
req["topic_id"] = topic.id
topic_type = req.get("type")
if topic_type == "challenge":
schema = ChallengeTopicSchema()
response = schema.load(req, session=db.session)
else:
return {"success": False}, 400
db.session.add(response.data)
db.session.commit()
response = schema.dump(response.data)
db.session.close()
return {"success": True, "data": response.data}
@admins_only
@topics_namespace.doc(
description="Endpoint to delete a specific Topic object of a specific type",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
@validate_args(
{"type": (str, None), "target_id": (int, 0)}, location="query",
)
def delete(self, query_args):
topic_type = query_args.get("type")
target_id = int(query_args.get("target_id", 0))
if topic_type == "challenge":
Model = ChallengeTopics
else:
return {"success": False}, 400
topic = Model.query.filter_by(id=target_id).first_or_404()
db.session.delete(topic)
db.session.commit()
db.session.close()
return {"success": True}
@topics_namespace.route("/<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}

View File

@@ -1,164 +0,0 @@
from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.request import validate_args
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.cache import clear_standings
from CTFd.constants import RawEnum
from CTFd.models import Unlocks, db, get_class_by_tablename
from CTFd.schemas.awards import AwardSchema
from CTFd.schemas.unlocks import UnlockSchema
from CTFd.utils.decorators import (
admins_only,
authed_only,
during_ctf_time_only,
require_verified_emails,
)
from CTFd.utils.helpers.models import build_model_filters
from CTFd.utils.user import get_current_user
unlocks_namespace = Namespace("unlocks", description="Endpoint to retrieve Unlocks")
UnlockModel = sqlalchemy_to_pydantic(Unlocks)
TransientUnlockModel = sqlalchemy_to_pydantic(Unlocks, exclude=["id"])
class UnlockDetailedSuccessResponse(APIDetailedSuccessResponse):
data: UnlockModel
class UnlockListSuccessResponse(APIListSuccessResponse):
data: List[UnlockModel]
unlocks_namespace.schema_model(
"UnlockDetailedSuccessResponse", UnlockDetailedSuccessResponse.apidoc()
)
unlocks_namespace.schema_model(
"UnlockListSuccessResponse", UnlockListSuccessResponse.apidoc()
)
@unlocks_namespace.route("")
class UnlockList(Resource):
@admins_only
@unlocks_namespace.doc(
description="Endpoint to get unlock objects in bulk",
responses={
200: ("Success", "UnlockListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
@validate_args(
{
"user_id": (int, None),
"team_id": (int, None),
"target": (int, None),
"type": (str, None),
"q": (str, None),
"field": (
RawEnum("UnlockFields", {"target": "target", "type": "type"}),
None,
),
},
location="query",
)
def get(self, query_args):
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
filters = build_model_filters(model=Unlocks, query=q, field=field)
unlocks = Unlocks.query.filter_by(**query_args).filter(*filters).all()
schema = UnlockSchema()
response = schema.dump(unlocks)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
@during_ctf_time_only
@require_verified_emails
@authed_only
@unlocks_namespace.doc(
description="Endpoint to create an unlock object. Used to unlock hints.",
responses={
200: ("Success", "UnlockDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self):
req = request.get_json()
user = get_current_user()
req["user_id"] = user.id
req["team_id"] = user.team_id
Model = get_class_by_tablename(req["type"])
target = Model.query.filter_by(id=req["target"]).first_or_404()
# We should use the team's score if in teams mode
# user.account gives the appropriate account based on team mode
if target.cost > user.account.score:
return (
{
"success": False,
"errors": {
"score": "You do not have enough points to unlock this hint"
},
},
400,
)
schema = UnlockSchema()
response = schema.load(req, session=db.session)
if response.errors:
return {"success": False, "errors": response.errors}, 400
# Search for an existing unlock that matches the target and type
# And matches either the requesting user id or the requesting team id
existing = Unlocks.query.filter(
Unlocks.target == req["target"],
Unlocks.type == req["type"],
Unlocks.account_id == user.account_id,
).first()
if existing:
return (
{
"success": False,
"errors": {"target": "You've already unlocked this this target"},
},
400,
)
db.session.add(response.data)
award_schema = AwardSchema()
award = {
"user_id": user.id,
"team_id": user.team_id,
"name": target.name,
"description": target.description,
"value": (-target.cost),
"category": target.category,
}
award = award_schema.load(award)
db.session.add(award.data)
db.session.commit()
clear_standings()
response = schema.dump(response.data)
return {"success": True, "data": response.data}

View File

@@ -1,502 +0,0 @@
from typing import List
from flask import abort, request, session
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.request import validate_args
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import (
APIDetailedSuccessResponse,
PaginatedAPIListSuccessResponse,
)
from CTFd.cache import clear_challenges, clear_standings, clear_user_session
from CTFd.constants import RawEnum
from CTFd.models import (
Awards,
Notifications,
Solves,
Submissions,
Tracking,
Unlocks,
Users,
db,
)
from CTFd.schemas.awards import AwardSchema
from CTFd.schemas.submissions import SubmissionSchema
from CTFd.schemas.users import UserSchema
from CTFd.utils.config import get_mail_provider
from CTFd.utils.decorators import admins_only, authed_only, ratelimit
from CTFd.utils.decorators.visibility import (
check_account_visibility,
check_score_visibility,
)
from CTFd.utils.email import sendmail, user_created_notification
from CTFd.utils.helpers.models import build_model_filters
from CTFd.utils.security.auth import update_user
from CTFd.utils.user import get_current_user, get_current_user_type, is_admin
users_namespace = Namespace("users", description="Endpoint to retrieve Users")
UserModel = sqlalchemy_to_pydantic(Users)
TransientUserModel = sqlalchemy_to_pydantic(Users, exclude=["id"])
class UserDetailedSuccessResponse(APIDetailedSuccessResponse):
data: UserModel
class UserListSuccessResponse(PaginatedAPIListSuccessResponse):
data: List[UserModel]
users_namespace.schema_model(
"UserDetailedSuccessResponse", UserDetailedSuccessResponse.apidoc()
)
users_namespace.schema_model(
"UserListSuccessResponse", UserListSuccessResponse.apidoc()
)
@users_namespace.route("")
class UserList(Resource):
@check_account_visibility
@users_namespace.doc(
description="Endpoint to get User objects in bulk",
responses={
200: ("Success", "UserListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
@validate_args(
{
"affiliation": (str, None),
"country": (str, None),
"bracket": (str, None),
"q": (str, None),
"field": (
RawEnum(
"UserFields",
{
"name": "name",
"website": "website",
"country": "country",
"bracket": "bracket",
"affiliation": "affiliation",
},
),
None,
),
},
location="query",
)
def get(self, query_args):
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
filters = build_model_filters(model=Users, query=q, field=field)
if is_admin() and request.args.get("view") == "admin":
users = (
Users.query.filter_by(**query_args)
.filter(*filters)
.paginate(per_page=50, max_per_page=100)
)
else:
users = (
Users.query.filter_by(banned=False, hidden=False, **query_args)
.filter(*filters)
.paginate(per_page=50, max_per_page=100)
)
response = UserSchema(view="user", many=True).dump(users.items)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {
"meta": {
"pagination": {
"page": users.page,
"next": users.next_num,
"prev": users.prev_num,
"pages": users.pages,
"per_page": users.per_page,
"total": users.total,
}
},
"success": True,
"data": response.data,
}
@admins_only
@users_namespace.doc(
description="Endpoint to create a User object",
responses={
200: ("Success", "UserDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
params={
"notify": "Whether to send the created user an email with their credentials"
},
)
def post(self):
req = request.get_json()
schema = UserSchema("admin")
response = schema.load(req)
if response.errors:
return {"success": False, "errors": response.errors}, 400
db.session.add(response.data)
db.session.commit()
if request.args.get("notify"):
name = response.data.name
email = response.data.email
password = req.get("password")
user_created_notification(addr=email, name=name, password=password)
clear_standings()
clear_challenges()
response = schema.dump(response.data)
return {"success": True, "data": response.data}
@users_namespace.route("/<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
response["place"] = user.place
response["score"] = user.score
return {"success": True, "data": response}
@authed_only
@users_namespace.doc(
description="Endpoint to edit the User object for the current user",
responses={
200: ("Success", "UserDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def patch(self):
user = get_current_user()
data = request.get_json()
schema = UserSchema(view="self", instance=user, partial=True)
response = schema.load(data)
if response.errors:
return {"success": False, "errors": response.errors}, 400
db.session.commit()
# Update user's session for the new session hash
update_user(user)
response = schema.dump(response.data)
db.session.close()
clear_standings()
clear_challenges()
return {"success": True, "data": response.data}
@users_namespace.route("/me/solves")
class UserPrivateSolves(Resource):
@authed_only
def get(self):
user = get_current_user()
solves = user.get_solves(admin=True)
view = "user" if not is_admin() else "admin"
response = SubmissionSchema(view=view, many=True).dump(solves)
if response.errors:
return {"success": False, "errors": response.errors}, 400
count = len(response.data)
return {"success": True, "data": response.data, "meta": {"count": count}}
@users_namespace.route("/me/fails")
class UserPrivateFails(Resource):
@authed_only
def get(self):
user = get_current_user()
fails = user.get_fails(admin=True)
view = "user" if not is_admin() else "admin"
# We want to return the count purely for stats & graphs
# but this data isn't really needed by the end user.
# Only actually show fail data for admins.
if is_admin():
response = SubmissionSchema(view=view, many=True).dump(fails)
if response.errors:
return {"success": False, "errors": response.errors}, 400
data = response.data
else:
data = []
count = len(fails)
return {"success": True, "data": data, "meta": {"count": count}}
@users_namespace.route("/me/awards")
@users_namespace.param("user_id", "User ID")
class UserPrivateAwards(Resource):
@authed_only
def get(self):
user = get_current_user()
awards = user.get_awards(admin=True)
view = "user" if not is_admin() else "admin"
response = AwardSchema(view=view, many=True).dump(awards)
if response.errors:
return {"success": False, "errors": response.errors}, 400
count = len(response.data)
return {"success": True, "data": response.data, "meta": {"count": count}}
@users_namespace.route("/<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,
)

View File

@@ -1,569 +0,0 @@
import base64 # noqa: I001
import requests
from flask import Blueprint, abort
from flask import current_app as app
from flask import redirect, render_template, request, session, url_for
from itsdangerous.exc import BadSignature, BadTimeSignature, SignatureExpired
from CTFd.cache import clear_team_session, clear_user_session
from CTFd.models import Teams, UserFieldEntries, UserFields, Users, db
from CTFd.utils import config, email, get_app_config, get_config
from CTFd.utils import user as current_user
from CTFd.utils import validators
from CTFd.utils.config import is_teams_mode
from CTFd.utils.config.integrations import mlc_registration
from CTFd.utils.config.visibility import registration_visible
from CTFd.utils.crypto import verify_password
from CTFd.utils.decorators import ratelimit
from CTFd.utils.decorators.visibility import check_registration_visibility
from CTFd.utils.helpers import error_for, get_errors, markup
from CTFd.utils.logging import log
from CTFd.utils.modes import TEAMS_MODE
from CTFd.utils.security.auth import login_user, logout_user
from CTFd.utils.security.signing import unserialize
from CTFd.utils.validators import ValidationError
auth = Blueprint("auth", __name__)
@auth.route("/confirm", methods=["POST", "GET"])
@auth.route("/confirm/<data>", methods=["POST", "GET"])
@ratelimit(method="POST", limit=10, interval=60)
def confirm(data=None):
if not get_config("verify_emails"):
# If the CTF doesn't care about confirming email addresses then redierct to challenges
return redirect(url_for("challenges.listing"))
# User is confirming email account
if data and request.method == "GET":
try:
user_email = unserialize(data, max_age=1800)
except (BadTimeSignature, SignatureExpired):
return render_template(
"confirm.html", errors=["Your confirmation link has expired"]
)
except (BadSignature, TypeError, base64.binascii.Error):
return render_template(
"confirm.html", errors=["Your confirmation token is invalid"]
)
user = Users.query.filter_by(email=user_email).first_or_404()
if user.verified:
return redirect(url_for("views.settings"))
user.verified = True
log(
"registrations",
format="[{date}] {ip} - successful confirmation for {name}",
name=user.name,
)
db.session.commit()
clear_user_session(user_id=user.id)
email.successful_registration_notification(user.email)
db.session.close()
if current_user.authed():
return redirect(url_for("challenges.listing"))
return redirect(url_for("auth.login"))
# User is trying to start or restart the confirmation flow
if current_user.authed() is False:
return redirect(url_for("auth.login"))
user = Users.query.filter_by(id=session["id"]).first_or_404()
if user.verified:
return redirect(url_for("views.settings"))
if data is None:
if request.method == "POST":
# User wants to resend their confirmation email
email.verify_email_address(user.email)
log(
"registrations",
format="[{date}] {ip} - {name} initiated a confirmation email resend",
name=user.name,
)
return render_template(
"confirm.html", infos=[f"Confirmation email sent to {user.email}!"]
)
elif request.method == "GET":
# User has been directed to the confirm page
return render_template("confirm.html")
@auth.route("/reset_password", methods=["POST", "GET"])
@auth.route("/reset_password/<data>", methods=["POST", "GET"])
@ratelimit(method="POST", limit=10, interval=60)
def reset_password(data=None):
if config.can_send_mail() is False:
return render_template(
"reset_password.html",
errors=[
markup(
"This CTF is not configured to send email.<br> Please contact an organizer to have your password reset."
)
],
)
if data is not None:
try:
email_address = unserialize(data, max_age=1800)
except (BadTimeSignature, SignatureExpired):
return render_template(
"reset_password.html", errors=["Your link has expired"]
)
except (BadSignature, TypeError, base64.binascii.Error):
return render_template(
"reset_password.html", errors=["Your reset token is invalid"]
)
if request.method == "GET":
return render_template("reset_password.html", mode="set")
if request.method == "POST":
password = request.form.get("password", "").strip()
user = Users.query.filter_by(email=email_address).first_or_404()
if user.oauth_id:
return render_template(
"reset_password.html",
infos=[
"Your account was registered via an authentication provider and does not have an associated password. Please login via your authentication provider."
],
)
pass_short = len(password) == 0
if pass_short:
return render_template(
"reset_password.html", errors=["Please pick a longer password"]
)
user.password = password
db.session.commit()
clear_user_session(user_id=user.id)
log(
"logins",
format="[{date}] {ip} - successful password reset for {name}",
name=user.name,
)
db.session.close()
email.password_change_alert(user.email)
return redirect(url_for("auth.login"))
if request.method == "POST":
email_address = request.form["email"].strip()
user = Users.query.filter_by(email=email_address).first()
get_errors()
if not user:
return render_template(
"reset_password.html",
infos=[
"If that account exists you will receive an email, please check your inbox"
],
)
if user.oauth_id:
return render_template(
"reset_password.html",
infos=[
"The email address associated with this account was registered via an authentication provider and does not have an associated password. Please login via your authentication provider."
],
)
email.forgot_password(email_address)
return render_template(
"reset_password.html",
infos=[
"If that account exists you will receive an email, please check your inbox"
],
)
return render_template("reset_password.html")
@auth.route("/register", methods=["POST", "GET"])
@check_registration_visibility
@ratelimit(method="POST", limit=10, interval=5)
def register():
errors = get_errors()
if current_user.authed():
return redirect(url_for("challenges.listing"))
if request.method == "POST":
name = request.form.get("name", "").strip()
email_address = request.form.get("email", "").strip().lower()
password = request.form.get("password", "").strip()
website = request.form.get("website")
affiliation = request.form.get("affiliation")
country = request.form.get("country")
registration_code = str(request.form.get("registration_code", ""))
name_len = len(name) == 0
names = Users.query.add_columns("name", "id").filter_by(name=name).first()
emails = (
Users.query.add_columns("email", "id")
.filter_by(email=email_address)
.first()
)
pass_short = len(password) == 0
pass_long = len(password) > 128
valid_email = validators.validate_email(email_address)
team_name_email_check = validators.validate_email(name)
if get_config("registration_code"):
if (
registration_code.lower()
!= str(get_config("registration_code", default="")).lower()
):
errors.append("The registration code you entered was incorrect")
# Process additional user fields
fields = {}
for field in UserFields.query.all():
fields[field.id] = field
entries = {}
for field_id, field in fields.items():
value = request.form.get(f"fields[{field_id}]", "").strip()
if field.required is True and (value is None or value == ""):
errors.append("Please provide all required fields")
break
# Handle special casing of existing profile fields
if field.name.lower() == "affiliation":
affiliation = value
break
elif field.name.lower() == "website":
website = value
break
if field.field_type == "boolean":
entries[field_id] = bool(value)
else:
entries[field_id] = value
if country:
try:
validators.validate_country_code(country)
valid_country = True
except ValidationError:
valid_country = False
else:
valid_country = True
if website:
valid_website = validators.validate_url(website)
else:
valid_website = True
if affiliation:
valid_affiliation = len(affiliation) < 128
else:
valid_affiliation = True
if not valid_email:
errors.append("Please enter a valid email address")
if email.check_email_is_whitelisted(email_address) is False:
errors.append("Your email address is not from an allowed domain")
if names:
errors.append("That user name is already taken")
if team_name_email_check is True:
errors.append("Your user name cannot be an email address")
if emails:
errors.append("That email has already been used")
if pass_short:
errors.append("Pick a longer password")
if pass_long:
errors.append("Pick a shorter password")
if name_len:
errors.append("Pick a longer user name")
if valid_website is False:
errors.append("Websites must be a proper URL starting with http or https")
if valid_country is False:
errors.append("Invalid country")
if valid_affiliation is False:
errors.append("Please provide a shorter affiliation")
if len(errors) > 0:
return render_template(
"register.html",
errors=errors,
name=request.form["name"],
email=request.form["email"],
password=request.form["password"],
)
else:
with app.app_context():
user = Users(name=name, email=email_address, password=password)
if website:
user.website = website
if affiliation:
user.affiliation = affiliation
if country:
user.country = country
db.session.add(user)
db.session.commit()
db.session.flush()
for field_id, value in entries.items():
entry = UserFieldEntries(
field_id=field_id, value=value, user_id=user.id
)
db.session.add(entry)
db.session.commit()
login_user(user)
if request.args.get("next") and validators.is_safe_url(
request.args.get("next")
):
return redirect(request.args.get("next"))
if config.can_send_mail() and get_config(
"verify_emails"
): # Confirming users is enabled and we can send email.
log(
"registrations",
format="[{date}] {ip} - {name} registered (UNCONFIRMED) with {email}",
name=user.name,
email=user.email,
)
email.verify_email_address(user.email)
db.session.close()
return redirect(url_for("auth.confirm"))
else: # Don't care about confirming users
if (
config.can_send_mail()
): # We want to notify the user that they have registered.
email.successful_registration_notification(user.email)
log(
"registrations",
format="[{date}] {ip} - {name} registered with {email}",
name=user.name,
email=user.email,
)
db.session.close()
if is_teams_mode():
return redirect(url_for("teams.private"))
return redirect(url_for("challenges.listing"))
else:
return render_template("register.html", errors=errors)
@auth.route("/login", methods=["POST", "GET"])
@ratelimit(method="POST", limit=10, interval=5)
def login():
errors = get_errors()
if request.method == "POST":
name = request.form["name"]
# Check if the user submitted an email address or a team name
if validators.validate_email(name) is True:
user = Users.query.filter_by(email=name).first()
else:
user = Users.query.filter_by(name=name).first()
if user:
if user.password is None:
errors.append(
"Your account was registered with a 3rd party authentication provider. "
"Please try logging in with a configured authentication provider."
)
return render_template("login.html", errors=errors)
if user and verify_password(request.form["password"], user.password):
session.regenerate()
login_user(user)
log("logins", "[{date}] {ip} - {name} logged in", name=user.name)
db.session.close()
if request.args.get("next") and validators.is_safe_url(
request.args.get("next")
):
return redirect(request.args.get("next"))
return redirect(url_for("challenges.listing"))
else:
# This user exists but the password is wrong
log(
"logins",
"[{date}] {ip} - submitted invalid password for {name}",
name=user.name,
)
errors.append("Your username or password is incorrect")
db.session.close()
return render_template("login.html", errors=errors)
else:
# This user just doesn't exist
log("logins", "[{date}] {ip} - submitted invalid account information")
errors.append("Your username or password is incorrect")
db.session.close()
return render_template("login.html", errors=errors)
else:
db.session.close()
return render_template("login.html", errors=errors)
@auth.route("/oauth")
def oauth_login():
endpoint = (
get_app_config("OAUTH_AUTHORIZATION_ENDPOINT")
or get_config("oauth_authorization_endpoint")
or "https://auth.majorleaguecyber.org/oauth/authorize"
)
if get_config("user_mode") == "teams":
scope = "profile team"
else:
scope = "profile"
client_id = get_app_config("OAUTH_CLIENT_ID") or get_config("oauth_client_id")
if client_id is None:
error_for(
endpoint="auth.login",
message="OAuth Settings not configured. "
"Ask your CTF administrator to configure MajorLeagueCyber integration.",
)
return redirect(url_for("auth.login"))
redirect_url = "{endpoint}?response_type=code&client_id={client_id}&scope={scope}&state={state}".format(
endpoint=endpoint, client_id=client_id, scope=scope, state=session["nonce"]
)
return redirect(redirect_url)
@auth.route("/redirect", methods=["GET"])
@ratelimit(method="GET", limit=10, interval=60)
def oauth_redirect():
oauth_code = request.args.get("code")
state = request.args.get("state")
if session["nonce"] != state:
log("logins", "[{date}] {ip} - OAuth State validation mismatch")
error_for(endpoint="auth.login", message="OAuth State validation mismatch.")
return redirect(url_for("auth.login"))
if oauth_code:
url = (
get_app_config("OAUTH_TOKEN_ENDPOINT")
or get_config("oauth_token_endpoint")
or "https://auth.majorleaguecyber.org/oauth/token"
)
client_id = get_app_config("OAUTH_CLIENT_ID") or get_config("oauth_client_id")
client_secret = get_app_config("OAUTH_CLIENT_SECRET") or get_config(
"oauth_client_secret"
)
headers = {"content-type": "application/x-www-form-urlencoded"}
data = {
"code": oauth_code,
"client_id": client_id,
"client_secret": client_secret,
"grant_type": "authorization_code",
}
token_request = requests.post(url, data=data, headers=headers)
if token_request.status_code == requests.codes.ok:
token = token_request.json()["access_token"]
user_url = (
get_app_config("OAUTH_API_ENDPOINT")
or get_config("oauth_api_endpoint")
or "https://api.majorleaguecyber.org/user"
)
headers = {
"Authorization": "Bearer " + str(token),
"Content-type": "application/json",
}
api_data = requests.get(url=user_url, headers=headers).json()
user_id = api_data["id"]
user_name = api_data["name"]
user_email = api_data["email"]
user = Users.query.filter_by(email=user_email).first()
if user is None:
# Check if we are allowing registration before creating users
if registration_visible() or mlc_registration():
user = Users(
name=user_name,
email=user_email,
oauth_id=user_id,
verified=True,
)
db.session.add(user)
db.session.commit()
else:
log("logins", "[{date}] {ip} - Public registration via MLC blocked")
error_for(
endpoint="auth.login",
message="Public registration is disabled. Please try again later.",
)
return redirect(url_for("auth.login"))
if get_config("user_mode") == TEAMS_MODE and user.team_id is None:
team_id = api_data["team"]["id"]
team_name = api_data["team"]["name"]
team = Teams.query.filter_by(oauth_id=team_id).first()
if team is None:
num_teams_limit = int(get_config("num_teams", default=0))
num_teams = Teams.query.filter_by(
banned=False, hidden=False
).count()
if num_teams_limit and num_teams >= num_teams_limit:
abort(
403,
description=f"Reached the maximum number of teams ({num_teams_limit}). Please join an existing team.",
)
team = Teams(name=team_name, oauth_id=team_id, captain_id=user.id)
db.session.add(team)
db.session.commit()
clear_team_session(team_id=team.id)
team_size_limit = get_config("team_size", default=0)
if team_size_limit and len(team.members) >= team_size_limit:
plural = "" if team_size_limit == 1 else "s"
size_error = "Teams are limited to {limit} member{plural}.".format(
limit=team_size_limit, plural=plural
)
error_for(endpoint="auth.login", message=size_error)
return redirect(url_for("auth.login"))
team.members.append(user)
db.session.commit()
if user.oauth_id is None:
user.oauth_id = user_id
user.verified = True
db.session.commit()
clear_user_session(user_id=user.id)
login_user(user)
return redirect(url_for("challenges.listing"))
else:
log("logins", "[{date}] {ip} - OAuth token retrieval failure")
error_for(endpoint="auth.login", message="OAuth token retrieval failure.")
return redirect(url_for("auth.login"))
else:
log("logins", "[{date}] {ip} - Received redirect without OAuth code")
error_for(
endpoint="auth.login", message="Received redirect without OAuth code."
)
return redirect(url_for("auth.login"))
@auth.route("/logout")
def logout():
if current_user.authed():
logout_user()
return redirect(url_for("views.static_html"))

167
CTFd/cache/__init__.py vendored
View File

@@ -1,167 +0,0 @@
from functools import lru_cache, wraps
from time import monotonic_ns
from flask import request
from flask_caching import Cache, make_template_fragment_key
cache = Cache()
def timed_lru_cache(timeout: int = 300, maxsize: int = 64, typed: bool = False):
"""
lru_cache implementation that includes a time based expiry
Parameters:
seconds (int): Timeout in seconds to clear the WHOLE cache, default = 5 minutes
maxsize (int): Maximum Size of the Cache
typed (bool): Same value of different type will be a different entry
Implmentation from https://gist.github.com/Morreski/c1d08a3afa4040815eafd3891e16b945?permalink_comment_id=3437689#gistcomment-3437689
"""
def wrapper_cache(func):
func = lru_cache(maxsize=maxsize, typed=typed)(func)
func.delta = timeout * 10 ** 9
func.expiration = monotonic_ns() + func.delta
@wraps(func)
def wrapped_func(*args, **kwargs):
if monotonic_ns() >= func.expiration:
func.cache_clear()
func.expiration = monotonic_ns() + func.delta
return func(*args, **kwargs)
wrapped_func.cache_info = func.cache_info
wrapped_func.cache_clear = func.cache_clear
return wrapped_func
return wrapper_cache
def make_cache_key(path=None, key_prefix="view/%s"):
"""
This function mostly emulates Flask-Caching's `make_cache_key` function so we can delete cached api responses.
Over time this function may be replaced with a cleaner custom cache implementation.
:param path:
:param key_prefix:
:return:
"""
if path is None:
path = request.endpoint
cache_key = key_prefix % path
return cache_key
def clear_config():
from CTFd.utils import _get_config, get_app_config
cache.delete_memoized(_get_config)
cache.delete_memoized(get_app_config)
def clear_standings():
from CTFd.models import Users, Teams # noqa: I001
from CTFd.constants.static import CacheKeys
from CTFd.utils.scores import get_standings, get_team_standings, get_user_standings
from CTFd.api.v1.scoreboard import ScoreboardDetail, ScoreboardList
from CTFd.api import api
from CTFd.utils.user import (
get_user_score,
get_user_place,
get_team_score,
get_team_place,
)
# Clear out the bulk standings functions
cache.delete_memoized(get_standings)
cache.delete_memoized(get_team_standings)
cache.delete_memoized(get_user_standings)
# Clear out the individual helpers for accessing score via the model
cache.delete_memoized(Users.get_score)
cache.delete_memoized(Users.get_place)
cache.delete_memoized(Teams.get_score)
cache.delete_memoized(Teams.get_place)
# Clear the Jinja Attrs constants
cache.delete_memoized(get_user_score)
cache.delete_memoized(get_user_place)
cache.delete_memoized(get_team_score)
cache.delete_memoized(get_team_place)
# Clear out HTTP request responses
cache.delete(make_cache_key(path=api.name + "." + ScoreboardList.endpoint))
cache.delete(make_cache_key(path=api.name + "." + ScoreboardDetail.endpoint))
cache.delete_memoized(ScoreboardList.get)
# Clear out scoreboard templates
cache.delete(make_template_fragment_key(CacheKeys.PUBLIC_SCOREBOARD_TABLE))
def clear_challenges():
from CTFd.utils.challenges import get_all_challenges # noqa: I001
from CTFd.utils.challenges import get_solves_for_challenge_id
from CTFd.utils.challenges import get_solve_ids_for_user_id
from CTFd.utils.challenges import get_solve_counts_for_challenges
cache.delete_memoized(get_all_challenges)
cache.delete_memoized(get_solves_for_challenge_id)
cache.delete_memoized(get_solve_ids_for_user_id)
cache.delete_memoized(get_solve_counts_for_challenges)
def clear_pages():
from CTFd.utils.config.pages import get_page, get_pages
cache.delete_memoized(get_pages)
cache.delete_memoized(get_page)
def clear_user_recent_ips(user_id):
from CTFd.utils.user import get_user_recent_ips
cache.delete_memoized(get_user_recent_ips, user_id=user_id)
def clear_user_session(user_id):
from CTFd.utils.user import ( # noqa: I001
get_user_attrs,
get_user_place,
get_user_score,
get_user_recent_ips,
)
cache.delete_memoized(get_user_attrs, user_id=user_id)
cache.delete_memoized(get_user_place, user_id=user_id)
cache.delete_memoized(get_user_score, user_id=user_id)
cache.delete_memoized(get_user_recent_ips, user_id=user_id)
def clear_all_user_sessions():
from CTFd.utils.user import ( # noqa: I001
get_user_attrs,
get_user_place,
get_user_score,
get_user_recent_ips,
)
cache.delete_memoized(get_user_attrs)
cache.delete_memoized(get_user_place)
cache.delete_memoized(get_user_score)
cache.delete_memoized(get_user_recent_ips)
def clear_team_session(team_id):
from CTFd.utils.user import get_team_attrs, get_team_place, get_team_score
cache.delete_memoized(get_team_attrs, team_id=team_id)
cache.delete_memoized(get_team_place, team_id=team_id)
cache.delete_memoized(get_team_score, team_id=team_id)
def clear_all_team_sessions():
from CTFd.utils.user import get_team_attrs, get_team_place, get_team_score
cache.delete_memoized(get_team_attrs)
cache.delete_memoized(get_team_place)
cache.delete_memoized(get_team_score)

View File

@@ -1,48 +0,0 @@
from flask import Blueprint, redirect, render_template, request, url_for
from CTFd.constants.config import ChallengeVisibilityTypes, Configs
from CTFd.utils.config import is_teams_mode
from CTFd.utils.dates import ctf_ended, ctf_paused, ctf_started
from CTFd.utils.decorators import (
during_ctf_time_only,
require_complete_profile,
require_verified_emails,
)
from CTFd.utils.decorators.visibility import check_challenge_visibility
from CTFd.utils.helpers import get_errors, get_infos
from CTFd.utils.user import authed, get_current_team
challenges = Blueprint("challenges", __name__)
@challenges.route("/challenges", methods=["GET"])
@require_complete_profile
@during_ctf_time_only
@require_verified_emails
@check_challenge_visibility
def listing():
if (
Configs.challenge_visibility == ChallengeVisibilityTypes.PUBLIC
and authed() is False
):
pass
else:
if is_teams_mode() and get_current_team() is None:
return redirect(url_for("teams.private", next=request.full_path))
infos = get_infos()
errors = get_errors()
if Configs.challenge_visibility == ChallengeVisibilityTypes.ADMINS:
infos.append("Challenge Visibility is set to Admins Only")
if ctf_started() is False:
errors.append(f"{Configs.ctf_name} has not started yet")
if ctf_paused() is True:
infos.append(f"{Configs.ctf_name} is paused")
if ctf_ended() is True:
infos.append(f"{Configs.ctf_name} has ended")
return render_template("challenges.html", infos=infos, errors=errors)

View File

@@ -1,280 +0,0 @@
# CTFd Configuration File
#
# Use this file to configure aspects of how CTFd behaves. Additional attributes can be specified for
# plugins and other additional behavior.
#
# If a configuration item is specified but left empty, CTFd will do the following:
#
# 1. Look for an environment variable under the same name and use that value if found
# 2. Use a default value specified in it's own internal configuration
# 3. Use a null value (i.e. None) or empty string for the configuration value
[server]
# SECRET_KEY:
# The secret value used to creation sessions and sign strings. This should be set to a random string. In the
# interest of ease, CTFd will automatically create a secret key file for you. If you wish to add this secret key
# to your instance you should hard code this value to a random static value.
#
# You can also remove .ctfd_secret_key from the .gitignore file and commit this file into whatever repository
# you are using.
#
# http://flask.pocoo.org/docs/latest/quickstart/#sessions
SECRET_KEY =
# DATABASE_URL
# The URI that specifies the username, password, hostname, port, and database of the server
# used to hold the CTFd database.
#
# If neither this setting nor `DATABASE_HOST` is specified, CTFd will automatically create a SQLite database for you to use
# e.g. mysql+pymysql://root:<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]
# SESSION_COOKIE_HTTPONLY
# Controls if cookies should be set with the HttpOnly flag. Defaults to True.
SESSION_COOKIE_HTTPONLY = true
# SESSION_COOKIE_SAMESITE
# Controls the SameSite attribute on session cookies. Can be Lax or Strict.
# Should be left as Lax unless the implications are well understood
SESSION_COOKIE_SAMESITE = Lax
# PERMANENT_SESSION_LIFETIME
# The lifetime of a session. The default is 604800 seconds (7 days).
PERMANENT_SESSION_LIFETIME = 604800
[email]
# MAILFROM_ADDR
# The email address that emails are sent from if not overridden in the configuration panel.
MAILFROM_ADDR =
# MAIL_SERVER
# The mail server that emails are sent from if not overriden in the configuration panel.
MAIL_SERVER =
# MAIL_PORT
# The mail port that emails are sent from if not overriden in the configuration panel.
MAIL_PORT =
# MAIL_USEAUTH
# Whether or not to use username and password to authenticate to the SMTP server
MAIL_USEAUTH =
# MAIL_USERNAME
# The username used to authenticate to the SMTP server if MAIL_USEAUTH is defined
MAIL_USERNAME =
# MAIL_PASSWORD
# The password used to authenticate to the SMTP server if MAIL_USEAUTH is defined
MAIL_PASSWORD =
# MAIL_TLS
# Whether to connect to the SMTP server over TLS
MAIL_TLS =
# MAIL_SSL
# Whether to connect to the SMTP server over SSL
MAIL_SSL =
# MAILSENDER_ADDR
# The email address that is responsible for the transmission of emails.
# This is very often the MAILFROM_ADDR value but can be specified if your email
# is delivered by a different domain than what's specified in your MAILFROM_ADDR.
# If this isn't specified, the MAILFROM_ADDR value is used.
# It is fairly rare to need to set this value.
MAILSENDER_ADDR =
# MAILGUN_API_KEY
# Mailgun API key to send email over Mailgun. As of CTFd v3, Mailgun integration is deprecated.
# Installations using the Mailgun API should migrate over to SMTP settings.
MAILGUN_API_KEY =
# MAILGUN_BASE_URL
# Mailgun base url to send email over Mailgun. As of CTFd v3, Mailgun integration is deprecated.
# Installations using the Mailgun API should migrate over to SMTP settings.
MAILGUN_BASE_URL =
# MAIL_PROVIDER
# Specifies the email provider that CTFd will use to send email.
# By default CTFd will automatically detect the correct email provider based on the other settings
# specified here or in the configuration panel. This setting can be used to force a specific provider.
MAIL_PROVIDER =
[uploads]
# UPLOAD_PROVIDER
# Specifies the service that CTFd should use to store files.
# Can be set to filesystem or s3
UPLOAD_PROVIDER =
# UPLOAD_FOLDER
# The location where files are uploaded under the filesystem uploader.
# The default destination is the CTFd/uploads folder.
UPLOAD_FOLDER =
# AWS_ACCESS_KEY_ID
# AWS access token used to authenticate to the S3 bucket. Only used under the s3 uploader.
AWS_ACCESS_KEY_ID =
# AWS_SECRET_ACCESS_KEY
# AWS secret token used to authenticate to the S3 bucket. Only used under the s3 uploader.
AWS_SECRET_ACCESS_KEY =
# AWS_S3_BUCKET
# The unique identifier for your S3 bucket. Only used under the s3 uploader.
AWS_S3_BUCKET =
# AWS_S3_ENDPOINT_URL
# A URL pointing to a custom S3 implementation. Only used under the s3 uploader.
AWS_S3_ENDPOINT_URL =
# AWS_S3_REGION
# The aws region that hosts your bucket. Only used in the s3 uploader.
AWS_S3_REGION =
[logs]
# LOG_FOLDER
# The location where logs are written. These are the logs for CTFd key submissions, registrations, and logins. The default location is the CTFd/logs folder.
LOG_FOLDER =
[optional]
# REVERSE_PROXY
# Specifies whether CTFd is behind a reverse proxy or not. Set to true if using a reverse proxy like nginx.
# You can also specify a comma seperated set of numbers specifying the reverse proxy configuration settings.
# See https://werkzeug.palletsprojects.com/en/0.15.x/middleware/proxy_fix/#werkzeug.middleware.proxy_fix.ProxyFix.
# For example to configure `x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1` specify `1,1,1,1,1`.
# If you specify `true` CTFd will default to the above behavior with all proxy settings set to 1.
REVERSE_PROXY =
# THEME_FALLBACK
# Specifies whether CTFd will fallback to the default "core" theme for missing pages/content. Useful for developing themes or using incomplete themes.
# Defaults to true.
THEME_FALLBACK =
# TEMPLATES_AUTO_RELOAD
# Specifies whether Flask should check for modifications to templates and reload them automatically. Defaults to true.
TEMPLATES_AUTO_RELOAD =
# SQLALCHEMY_TRACK_MODIFICATIONS
# Automatically disabled to suppress warnings and save memory.
# You should only enable this if you need it.
# Defaults to false.
SQLALCHEMY_TRACK_MODIFICATIONS =
# SWAGGER_UI
# Enable the Swagger UI endpoint at /api/v1/
SWAGGER_UI =
# UPDATE_CHECK
# Specifies whether or not CTFd will check whether or not there is a new version of CTFd. Defaults True.
UPDATE_CHECK =
# APPLICATION_ROOT
# Specifies what path CTFd is mounted under. It can be used to run CTFd in a subdirectory.
# Example: /ctfd
APPLICATION_ROOT =
# SERVER_SENT_EVENTS
# Specifies whether or not to enable the Server-Sent Events based Notifications system.
# Defaults to true
SERVER_SENT_EVENTS =
# HTML_SANITIZATION
# Specifies whether CTFd should sanitize HTML content
# Defaults to false
HTML_SANITIZATION =
# SQLALCHEMY_MAX_OVERFLOW
# Specifies the max_overflow setting for SQLAlchemy's Engine
# https://docs.sqlalchemy.org/en/13/core/engines.html#sqlalchemy.create_engine
# https://flask-sqlalchemy.palletsprojects.com/en/2.x/config/#configuration-keys
SQLALCHEMY_MAX_OVERFLOW =
# SQLALCHEMY_POOL_PRE_PING
# Specifies the pool_pre_ping setting for SQLAlchemy's Engine
# https://docs.sqlalchemy.org/en/13/core/engines.html#sqlalchemy.create_engine
# https://flask-sqlalchemy.palletsprojects.com/en/2.x/config/#configuration-keys
SQLALCHEMY_POOL_PRE_PING =
# SAFE_MODE
# If SAFE_MODE is enabled, CTFd will not load any plugins which may alleviate issues preventing CTFd from starting
# Defaults to false
SAFE_MODE =
[oauth]
# OAUTH_CLIENT_ID
# Register an event at https://majorleaguecyber.org/ and use the Client ID here
OAUTH_CLIENT_ID =
# OAUTH_CLIENT_ID
# Register an event at https://majorleaguecyber.org/ and use the Client Secret here
OAUTH_CLIENT_SECRET =
[extra]
# The extra section can be used to specify additional values to be loaded into CTFd's configuration

View File

@@ -1,266 +0,0 @@
import configparser
import os
from distutils.util import strtobool
from typing import Union
from sqlalchemy.engine.url import URL
class EnvInterpolation(configparser.BasicInterpolation):
"""Interpolation which expands environment variables in values."""
def before_get(self, parser, section, option, value, defaults):
value = super().before_get(parser, section, option, value, defaults)
envvar = os.getenv(option)
if value == "" and envvar:
return process_string_var(envvar)
else:
return value
def process_string_var(value):
if value == "":
return None
if value.isdigit():
return int(value)
elif value.replace(".", "", 1).isdigit():
return float(value)
try:
return bool(strtobool(value))
except ValueError:
return value
def process_boolean_str(value):
if type(value) is bool:
return value
if value is None:
return False
if value == "":
return None
return bool(strtobool(value))
def empty_str_cast(value, default=None):
if value == "":
return default
return value
def gen_secret_key():
# Attempt to read the secret from the secret file
# This will fail if the secret has not been written
try:
with open(".ctfd_secret_key", "rb") as secret:
key = secret.read()
except OSError:
key = None
if not key:
key = os.urandom(64)
# Attempt to write the secret file
# This will fail if the filesystem is read-only
try:
with open(".ctfd_secret_key", "wb") as secret:
secret.write(key)
secret.flush()
except OSError:
pass
return key
config_ini = configparser.ConfigParser(interpolation=EnvInterpolation())
config_ini.optionxform = str # Makes the key value case-insensitive
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.ini")
config_ini.read(path)
# fmt: off
class ServerConfig(object):
SECRET_KEY: str = empty_str_cast(config_ini["server"]["SECRET_KEY"]) \
or gen_secret_key()
DATABASE_URL: str = empty_str_cast(config_ini["server"]["DATABASE_URL"])
if not DATABASE_URL:
if empty_str_cast(config_ini["server"]["DATABASE_HOST"]) is not None:
# construct URL from individual variables
DATABASE_URL = str(URL(
drivername=empty_str_cast(config_ini["server"]["DATABASE_PROTOCOL"]) or "mysql+pymysql",
username=empty_str_cast(config_ini["server"]["DATABASE_USER"]) or "ctfd",
password=empty_str_cast(config_ini["server"]["DATABASE_PASSWORD"]),
host=empty_str_cast(config_ini["server"]["DATABASE_HOST"]),
port=empty_str_cast(config_ini["server"]["DATABASE_PORT"]),
database=empty_str_cast(config_ini["server"]["DATABASE_NAME"]) or "ctfd",
))
else:
# default to local SQLite DB
DATABASE_URL = f"sqlite:///{os.path.dirname(os.path.abspath(__file__))}/ctfd.db"
REDIS_URL: str = empty_str_cast(config_ini["server"]["REDIS_URL"])
REDIS_HOST: str = empty_str_cast(config_ini["server"]["REDIS_HOST"])
REDIS_PROTOCOL: str = empty_str_cast(config_ini["server"]["REDIS_PROTOCOL"]) or "redis"
REDIS_USER: str = empty_str_cast(config_ini["server"]["REDIS_USER"])
REDIS_PASSWORD: str = empty_str_cast(config_ini["server"]["REDIS_PASSWORD"])
REDIS_PORT: int = empty_str_cast(config_ini["server"]["REDIS_PORT"]) or 6379
REDIS_DB: int = empty_str_cast(config_ini["server"]["REDIS_DB"]) or 0
if REDIS_URL or REDIS_HOST is None:
CACHE_REDIS_URL = REDIS_URL
else:
# construct URL from individual variables
CACHE_REDIS_URL = f"{REDIS_PROTOCOL}://"
if REDIS_USER:
CACHE_REDIS_URL += REDIS_USER
if REDIS_PASSWORD:
CACHE_REDIS_URL += f":{REDIS_PASSWORD}"
CACHE_REDIS_URL += f"@{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}"
SQLALCHEMY_DATABASE_URI = DATABASE_URL
if CACHE_REDIS_URL:
CACHE_TYPE: str = "redis"
else:
CACHE_TYPE: str = "filesystem"
CACHE_DIR: str = os.path.join(
os.path.dirname(__file__), os.pardir, ".data", "filesystem_cache"
)
# Override the threshold of cached values on the filesystem. The default is 500. Don't change unless you know what you're doing.
CACHE_THRESHOLD: int = 0
# === SECURITY ===
SESSION_COOKIE_HTTPONLY: bool = config_ini["security"].getboolean("SESSION_COOKIE_HTTPONLY", fallback=True)
SESSION_COOKIE_SAMESITE: str = empty_str_cast(config_ini["security"]["SESSION_COOKIE_SAMESITE"]) \
or "Lax"
PERMANENT_SESSION_LIFETIME: int = config_ini["security"].getint("PERMANENT_SESSION_LIFETIME") \
or 604800
"""
TRUSTED_PROXIES:
Defines a set of regular expressions used for finding a user's IP address if the CTFd instance
is behind a proxy. If you are running a CTF and users are on the same network as you, you may choose to remove
some proxies from the list.
CTFd only uses IP addresses for cursory tracking purposes. It is ill-advised to do anything complicated based
solely on IP addresses unless you know what you are doing.
"""
TRUSTED_PROXIES = [
r"^127\.0\.0\.1$",
# Remove the following proxies if you do not trust the local network
# For example if you are running a CTF on your laptop and the teams are
# all on the same network
r"^::1$",
r"^fc00:",
r"^10\.",
r"^172\.(1[6-9]|2[0-9]|3[0-1])\.",
r"^192\.168\.",
]
# === EMAIL ===
MAILFROM_ADDR: str = config_ini["email"]["MAILFROM_ADDR"] \
or "noreply@examplectf.com"
MAIL_SERVER: str = empty_str_cast(config_ini["email"]["MAIL_SERVER"])
MAIL_PORT: int = empty_str_cast(config_ini["email"]["MAIL_PORT"])
MAIL_USEAUTH: bool = process_boolean_str(config_ini["email"]["MAIL_USEAUTH"])
MAIL_USERNAME: str = empty_str_cast(config_ini["email"]["MAIL_USERNAME"])
MAIL_PASSWORD: str = empty_str_cast(config_ini["email"]["MAIL_PASSWORD"])
MAIL_TLS: bool = process_boolean_str(config_ini["email"]["MAIL_TLS"])
MAIL_SSL: bool = process_boolean_str(config_ini["email"]["MAIL_SSL"])
MAILSENDER_ADDR: str = empty_str_cast(config_ini["email"]["MAILSENDER_ADDR"])
MAILGUN_API_KEY: str = empty_str_cast(config_ini["email"]["MAILGUN_API_KEY"])
MAILGUN_BASE_URL: str = empty_str_cast(config_ini["email"]["MAILGUN_API_KEY"])
MAIL_PROVIDER: str = empty_str_cast(config_ini["email"].get("MAIL_PROVIDER"))
# === LOGS ===
LOG_FOLDER: str = empty_str_cast(config_ini["logs"]["LOG_FOLDER"]) \
or os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
# === UPLOADS ===
UPLOAD_PROVIDER: str = empty_str_cast(config_ini["uploads"]["UPLOAD_PROVIDER"]) \
or "filesystem"
UPLOAD_FOLDER: str = empty_str_cast(config_ini["uploads"]["UPLOAD_FOLDER"]) \
or os.path.join(os.path.dirname(os.path.abspath(__file__)), "uploads")
if UPLOAD_PROVIDER == "s3":
AWS_ACCESS_KEY_ID: str = empty_str_cast(config_ini["uploads"]["AWS_ACCESS_KEY_ID"])
AWS_SECRET_ACCESS_KEY: str = empty_str_cast(config_ini["uploads"]["AWS_SECRET_ACCESS_KEY"])
AWS_S3_BUCKET: str = empty_str_cast(config_ini["uploads"]["AWS_S3_BUCKET"])
AWS_S3_ENDPOINT_URL: str = empty_str_cast(config_ini["uploads"]["AWS_S3_ENDPOINT_URL"])
AWS_S3_REGION: str = empty_str_cast(config_ini["uploads"]["AWS_S3_REGION"])
# === OPTIONAL ===
REVERSE_PROXY: Union[str, bool] = empty_str_cast(config_ini["optional"]["REVERSE_PROXY"], default=False)
TEMPLATES_AUTO_RELOAD: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["TEMPLATES_AUTO_RELOAD"], default=True))
THEME_FALLBACK: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["THEME_FALLBACK"], default=True))
SQLALCHEMY_TRACK_MODIFICATIONS: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["SQLALCHEMY_TRACK_MODIFICATIONS"], default=False))
SWAGGER_UI: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["SWAGGER_UI"], default=False))
SWAGGER_UI_ENDPOINT: str = "/" if SWAGGER_UI else None
UPDATE_CHECK: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["UPDATE_CHECK"], default=True))
APPLICATION_ROOT: str = empty_str_cast(config_ini["optional"]["APPLICATION_ROOT"], default="/")
SERVER_SENT_EVENTS: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["SERVER_SENT_EVENTS"], default=True))
HTML_SANITIZATION: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["HTML_SANITIZATION"], default=False))
SAFE_MODE: bool = process_boolean_str(empty_str_cast(config_ini["optional"].get("SAFE_MODE", False), default=False))
if DATABASE_URL.startswith("sqlite") is False:
SQLALCHEMY_ENGINE_OPTIONS = {
"max_overflow": int(empty_str_cast(config_ini["optional"]["SQLALCHEMY_MAX_OVERFLOW"], default=20)), # noqa: E131
"pool_pre_ping": empty_str_cast(config_ini["optional"]["SQLALCHEMY_POOL_PRE_PING"], default=True), # noqa: E131
}
# === OAUTH ===
OAUTH_CLIENT_ID: str = empty_str_cast(config_ini["oauth"]["OAUTH_CLIENT_ID"])
OAUTH_CLIENT_SECRET: str = empty_str_cast(config_ini["oauth"]["OAUTH_CLIENT_SECRET"])
# fmt: on
class TestingConfig(ServerConfig):
SECRET_KEY = "AAAAAAAAAAAAAAAAAAAA"
PRESERVE_CONTEXT_ON_EXCEPTION = False
TESTING = True
DEBUG = True
SQLALCHEMY_DATABASE_URI = os.getenv("TESTING_DATABASE_URL") or "sqlite://"
MAIL_SERVER = os.getenv("TESTING_MAIL_SERVER")
SERVER_NAME = "localhost"
UPDATE_CHECK = False
REDIS_URL = None
CACHE_TYPE = "simple"
CACHE_THRESHOLD = 500
SAFE_MODE = True
# Actually initialize ServerConfig to allow us to add more attributes on
Config = ServerConfig()
for k, v in config_ini.items("extra"):
setattr(Config, k, v)

View File

@@ -1,66 +0,0 @@
from enum import Enum
from flask import current_app
JS_ENUMS = {}
JINJA_ENUMS = {}
class RawEnum(Enum):
"""
This is a customized enum class which should be used with a mixin.
The mixin should define the types of each member.
For example:
class Colors(str, RawEnum):
RED = "red"
GREEN = "green"
BLUE = "blue"
"""
def __str__(self):
return str(self._value_)
@classmethod
def keys(cls):
return list(cls.__members__.keys())
@classmethod
def values(cls):
return list(cls.__members__.values())
@classmethod
def test(cls, value):
try:
return bool(cls(value))
except ValueError:
return False
def JSEnum(cls):
"""
This is a decorator used to gather all Enums which should be shared with
the CTFd front end. The JS_Enums dictionary can be taken be a script and
compiled into a JavaScript file for use by frontend assets. JS_Enums
should not be passed directly into Jinja. A JinjaEnum is better for that.
"""
if cls.__name__ not in JS_ENUMS:
JS_ENUMS[cls.__name__] = dict(cls.__members__)
else:
raise KeyError("{} was already defined as a JSEnum".format(cls.__name__))
return cls
def JinjaEnum(cls):
"""
This is a decorator used to inject the decorated Enum into Jinja globals
which allows you to access it from the front end. If you need to access
an Enum from JS, a better tool to use is the JSEnum decorator.
"""
if cls.__name__ not in current_app.jinja_env.globals:
current_app.jinja_env.globals[cls.__name__] = cls
JINJA_ENUMS[cls.__name__] = cls
else:
raise KeyError("{} was already defined as a JinjaEnum".format(cls.__name__))
return cls

View File

@@ -1,44 +0,0 @@
import os
from flask import current_app, url_for
from CTFd.utils import get_asset_json
from CTFd.utils.config import ctf_theme
from CTFd.utils.helpers import markup
class _AssetsWrapper:
def manifest(self):
theme = ctf_theme()
manifest = os.path.join(
current_app.root_path, "themes", theme, "static", "manifest.json"
)
return get_asset_json(path=manifest)
def js(self, asset_key):
asset = self.manifest()[asset_key]
entry = asset["file"]
imports = asset.get("imports", [])
html = ""
for i in imports:
# TODO: Needs a better recursive solution
i = self.manifest()[i]["file"]
url = url_for("views.themes_beta", path=i)
html += f'<script defer type="module" src="{url}"></script>'
url = url_for("views.themes_beta", path=entry)
html += f'<script defer type="module" src="{url}"></script>'
return markup(html)
def css(self, asset_key):
asset = self.manifest()[asset_key]
entry = asset["file"]
url = url_for("views.themes_beta", path=entry)
return markup(f'<link rel="stylesheet" href="{url}">')
def file(self, asset_key):
asset = self.manifest()[asset_key]
entry = asset["file"]
return url_for("views.themes_beta", path=entry)
Assets = _AssetsWrapper()

View File

@@ -1,99 +0,0 @@
import json
from flask import url_for
from CTFd.constants import JinjaEnum, RawEnum
from CTFd.utils import get_config
class ConfigTypes(str, RawEnum):
CHALLENGE_VISIBILITY = "challenge_visibility"
SCORE_VISIBILITY = "score_visibility"
ACCOUNT_VISIBILITY = "account_visibility"
REGISTRATION_VISIBILITY = "registration_visibility"
@JinjaEnum
class UserModeTypes(str, RawEnum):
USERS = "users"
TEAMS = "teams"
@JinjaEnum
class ChallengeVisibilityTypes(str, RawEnum):
PUBLIC = "public"
PRIVATE = "private"
ADMINS = "admins"
@JinjaEnum
class ScoreVisibilityTypes(str, RawEnum):
PUBLIC = "public"
PRIVATE = "private"
HIDDEN = "hidden"
ADMINS = "admins"
@JinjaEnum
class AccountVisibilityTypes(str, RawEnum):
PUBLIC = "public"
PRIVATE = "private"
ADMINS = "admins"
@JinjaEnum
class RegistrationVisibilityTypes(str, RawEnum):
PUBLIC = "public"
PRIVATE = "private"
class _ConfigsWrapper:
def __getattr__(self, attr):
return get_config(attr)
@property
def ctf_name(self):
return get_config("ctf_name", default="CTFd")
@property
def ctf_small_icon(self):
icon = get_config("ctf_small_icon")
if icon:
return url_for("views.files", path=icon)
return url_for("views.themes", path="img/favicon.ico")
@property
def theme_header(self):
from CTFd.utils.helpers import markup
return markup(get_config("theme_header", default=""))
@property
def theme_footer(self):
from CTFd.utils.helpers import markup
return markup(get_config("theme_footer", default=""))
@property
def theme_settings(self):
try:
return json.loads(get_config("theme_settings", default="null"))
except json.JSONDecodeError:
return {"error": "invalid theme_settings"}
@property
def tos_or_privacy(self):
tos = bool(get_config("tos_url") or get_config("tos_text"))
privacy = bool(get_config("privacy_url") or get_config("privacy_text"))
return tos or privacy
@property
def tos_link(self):
return get_config("tos_url", default=url_for("views.tos"))
@property
def privacy_link(self):
return get_config("privacy_url", default=url_for("views.privacy"))
Configs = _ConfigsWrapper()

View File

@@ -1,18 +0,0 @@
from CTFd.constants import RawEnum
class Languages(str, RawEnum):
ENGLISH = "en"
GERMAN = "de"
POLISH = "pl"
LANGUAGE_NAMES = {
"en": "English",
"de": "Deutsch",
"pl": "Polski",
}
SELECT_LANGUAGE_LIST = [("", "")] + [
(str(lang), LANGUAGE_NAMES.get(str(lang))) for lang in Languages
]

View File

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

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

View File

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

View File

@@ -1,43 +0,0 @@
from collections import namedtuple
TeamAttrs = namedtuple(
"TeamAttrs",
[
"id",
"oauth_id",
"name",
"email",
"secret",
"website",
"affiliation",
"country",
"bracket",
"hidden",
"banned",
"captain_id",
"created",
],
)
class _TeamAttrsWrapper:
def __getattr__(self, attr):
from CTFd.utils.user import get_current_team_attrs
attrs = get_current_team_attrs()
return getattr(attrs, attr, None)
@property
def place(self):
from CTFd.utils.user import get_team_place
return get_team_place(team_id=self.id)
@property
def score(self):
from CTFd.utils.user import get_team_score
return get_team_score(team_id=self.id)
Team = _TeamAttrsWrapper()

View File

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

View File

@@ -1,46 +0,0 @@
from collections import namedtuple
UserAttrs = namedtuple(
"UserAttrs",
[
"id",
"oauth_id",
"name",
"email",
"type",
"secret",
"website",
"affiliation",
"country",
"bracket",
"hidden",
"banned",
"verified",
"language",
"team_id",
"created",
],
)
class _UserAttrsWrapper:
def __getattr__(self, attr):
from CTFd.utils.user import get_current_user_attrs
attrs = get_current_user_attrs()
return getattr(attrs, attr, None)
@property
def place(self):
from CTFd.utils.user import get_user_place
return get_user_place(user_id=self.id)
@property
def score(self):
from CTFd.utils.user import get_user_score
return get_user_score(user_id=self.id)
User = _UserAttrsWrapper()

View File

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

View File

@@ -1,22 +0,0 @@
from flask import Blueprint, Response, current_app, stream_with_context
from CTFd.utils import get_app_config
from CTFd.utils.decorators import authed_only, ratelimit
events = Blueprint("events", __name__)
@events.route("/events")
@authed_only
@ratelimit(method="GET", limit=150, interval=60)
def subscribe():
@stream_with_context
def gen():
for event in current_app.events_manager.subscribe():
yield str(event)
enabled = get_app_config("SERVER_SENT_EVENTS")
if enabled is False:
return ("", 204)
return Response(gen(), mimetype="text/event-stream")

View File

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

View File

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

View File

@@ -1,62 +0,0 @@
from flask_babel import lazy_gettext as _l
from wtforms import PasswordField, StringField
from wtforms.fields.html5 import EmailField
from wtforms.validators import InputRequired
from CTFd.forms import BaseForm
from CTFd.forms.fields import SubmitField
from CTFd.forms.users import (
attach_custom_user_fields,
attach_registration_code_field,
build_custom_user_fields,
build_registration_code_field,
)
def RegistrationForm(*args, **kwargs):
class _RegistrationForm(BaseForm):
name = StringField(
_l("User Name"), validators=[InputRequired()], render_kw={"autofocus": True}
)
email = EmailField(_l("Email"), validators=[InputRequired()])
password = PasswordField(_l("Password"), validators=[InputRequired()])
submit = SubmitField(_l("Submit"))
@property
def extra(self):
return build_custom_user_fields(
self, include_entries=False, blacklisted_items=()
) + build_registration_code_field(self)
attach_custom_user_fields(_RegistrationForm)
attach_registration_code_field(_RegistrationForm)
return _RegistrationForm(*args, **kwargs)
class LoginForm(BaseForm):
name = StringField(
_l("User Name or Email"),
validators=[InputRequired()],
render_kw={"autofocus": True},
)
password = PasswordField(_l("Password"), validators=[InputRequired()])
submit = SubmitField(_l("Submit"))
class ConfirmForm(BaseForm):
submit = SubmitField(_l("Resend Confirmation Email"))
class ResetPasswordRequestForm(BaseForm):
email = EmailField(
_l("Email"), validators=[InputRequired()], render_kw={"autofocus": True}
)
submit = SubmitField(_l("Submit"))
class ResetPasswordForm(BaseForm):
password = PasswordField(
_l("Password"), validators=[InputRequired()], render_kw={"autofocus": True}
)
submit = SubmitField(_l("Submit"))

View File

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

View File

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

View File

@@ -1,110 +0,0 @@
from wtforms import BooleanField, FileField, SelectField, StringField, TextAreaField
from wtforms.fields.html5 import IntegerField, URLField
from wtforms.widgets.html5 import NumberInput
from CTFd.forms import BaseForm
from CTFd.forms.fields import SubmitField
from CTFd.utils.csv import get_dumpable_tables
class ResetInstanceForm(BaseForm):
accounts = BooleanField(
"Accounts",
description="Deletes all user and team accounts and their associated information",
)
submissions = BooleanField(
"Submissions",
description="Deletes all records that accounts gained points or took an action",
)
challenges = BooleanField(
"Challenges", description="Deletes all challenges and associated data"
)
pages = BooleanField(
"Pages", description="Deletes all pages and their associated files"
)
notifications = BooleanField(
"Notifications", description="Deletes all notifications"
)
submit = SubmitField("Reset CTF")
class AccountSettingsForm(BaseForm):
domain_whitelist = StringField(
"Account Email Whitelist",
description="Comma-seperated email domains which users can register under (e.g. ctfd.io, gmail.com, yahoo.com)",
)
team_creation = SelectField(
"Team Creation",
description="Control whether users can create their own teams (Teams mode only)",
choices=[("true", "Enabled"), ("false", "Disabled")],
default="true",
)
team_size = IntegerField(
widget=NumberInput(min=0),
description="Amount of users per team (Teams mode only)",
)
num_teams = IntegerField(
"Total Number of Teams",
widget=NumberInput(min=0),
description="Max number of teams (Teams mode only)",
)
verify_emails = SelectField(
"Verify Emails",
description="Control whether users must confirm their email addresses before playing",
choices=[("true", "Enabled"), ("false", "Disabled")],
default="false",
)
team_disbanding = SelectField(
"Team Disbanding",
description="Control whether team captains are allowed to disband their own teams",
choices=[
("inactive_only", "Enabled for Inactive Teams"),
("disabled", "Disabled"),
],
default="inactive_only",
)
name_changes = SelectField(
"Name Changes",
description="Control whether users and teams can change their names",
choices=[("true", "Enabled"), ("false", "Disabled")],
default="true",
)
incorrect_submissions_per_min = IntegerField(
"Incorrect Submissions per Minute",
widget=NumberInput(min=1),
description="Amount of submissions allowed per minute for flag bruteforce protection (default: 10)",
)
submit = SubmitField("Update")
class ExportCSVForm(BaseForm):
table = SelectField("Database Table", choices=get_dumpable_tables())
submit = SubmitField("Download CSV")
class ImportCSVForm(BaseForm):
csv_type = SelectField(
"CSV Type",
choices=[("users", "Users"), ("teams", "Teams"), ("challenges", "Challenges")],
description="Type of CSV data",
)
csv_file = FileField("CSV File", description="CSV file contents")
class LegalSettingsForm(BaseForm):
tos_url = URLField(
"Terms of Service URL",
description="External URL to a Terms of Service document hosted elsewhere",
)
tos_text = TextAreaField(
"Terms of Service", description="Text shown on the Terms of Service page",
)
privacy_url = URLField(
"Privacy Policy URL",
description="External URL to a Privacy Policy document hosted elsewhere",
)
privacy_text = TextAreaField(
"Privacy Policy", description="Text shown on the Privacy Policy page",
)
submit = SubmitField("Update")

View File

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

View File

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

View File

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

View File

@@ -1,41 +0,0 @@
from wtforms import (
BooleanField,
HiddenField,
MultipleFileField,
SelectField,
StringField,
TextAreaField,
)
from wtforms.validators import InputRequired
from CTFd.forms import BaseForm
class PageEditForm(BaseForm):
title = StringField(
"Title", description="This is the title shown on the navigation bar"
)
route = StringField(
"Route",
description="This is the URL route that your page will be at (e.g. /page). You can also enter links to link to that page.",
)
draft = BooleanField("Draft")
hidden = BooleanField("Hidden")
auth_required = BooleanField("Authentication Required")
content = TextAreaField("Content")
format = SelectField(
"Format",
choices=[("markdown", "Markdown"), ("html", "HTML")],
default="markdown",
validators=[InputRequired()],
description="The markup format used to render the page",
)
class PageFilesUploadForm(BaseForm):
file = MultipleFileField(
"Upload Files",
description="Attach multiple files using Control+Click or Cmd+Click.",
validators=[InputRequired()],
)
type = HiddenField("Page Type", default="page", validators=[InputRequired()])

View File

@@ -1,53 +0,0 @@
from flask import session
from flask_babel import lazy_gettext as _l
from wtforms import PasswordField, SelectField, StringField
from wtforms.fields.html5 import DateField, URLField
from CTFd.constants.languages import SELECT_LANGUAGE_LIST
from CTFd.forms import BaseForm
from CTFd.forms.fields import SubmitField
from CTFd.forms.users import attach_custom_user_fields, build_custom_user_fields
from CTFd.utils.countries import SELECT_COUNTRIES_LIST
from CTFd.utils.user import get_current_user
def SettingsForm(*args, **kwargs):
class _SettingsForm(BaseForm):
name = StringField(_l("User Name"))
email = StringField(_l("Email"))
language = SelectField(_l("Language"), choices=SELECT_LANGUAGE_LIST)
password = PasswordField(_l("Password"))
confirm = PasswordField(_l("Current Password"))
affiliation = StringField(_l("Affiliation"))
website = URLField(_l("Website"))
country = SelectField(_l("Country"), choices=SELECT_COUNTRIES_LIST)
submit = SubmitField(_l("Submit"))
@property
def extra(self):
fields_kwargs = _SettingsForm.get_field_kwargs()
return build_custom_user_fields(
self,
include_entries=True,
fields_kwargs=fields_kwargs,
field_entries_kwargs={"user_id": session["id"]},
)
@staticmethod
def get_field_kwargs():
user = get_current_user()
field_kwargs = {"editable": True}
if user.filled_all_required_fields is False:
# Show all fields
field_kwargs = {}
return field_kwargs
field_kwargs = _SettingsForm.get_field_kwargs()
attach_custom_user_fields(_SettingsForm, **field_kwargs)
return _SettingsForm(*args, **kwargs)
class TokensForm(BaseForm):
expiration = DateField(_l("Expiration"))
submit = SubmitField(_l("Generate"))

View File

@@ -1,90 +0,0 @@
from flask_babel import lazy_gettext as _l
from wtforms import (
FileField,
HiddenField,
PasswordField,
RadioField,
SelectField,
StringField,
TextAreaField,
)
from wtforms.fields.html5 import EmailField
from wtforms.validators import InputRequired
from CTFd.constants.themes import DEFAULT_THEME
from CTFd.forms import BaseForm
from CTFd.forms.fields import SubmitField
from CTFd.utils.config import get_themes
class SetupForm(BaseForm):
ctf_name = StringField(
_l("Event Name"), description=_l("The name of your CTF event/workshop")
)
ctf_description = TextAreaField(
_l("Event Description"), description=_l("Description for the CTF")
)
user_mode = RadioField(
_l("User Mode"),
choices=[("teams", _l("Team Mode")), ("users", _l("User Mode"))],
default="teams",
description=_l(
"Controls whether users join together in teams to play (Team Mode) or play as themselves (User Mode)"
),
validators=[InputRequired()],
)
name = StringField(
_l("Admin Username"),
description=_l("Your username for the administration account"),
validators=[InputRequired()],
)
email = EmailField(
_l("Admin Email"),
description=_l("Your email address for the administration account"),
validators=[InputRequired()],
)
password = PasswordField(
_l("Admin Password"),
description=_l("Your password for the administration account"),
validators=[InputRequired()],
)
ctf_logo = FileField(
_l("Logo"),
description=_l(
"Logo to use for the website instead of a CTF name. Used as the home page button. Optional."
),
)
ctf_banner = FileField(
_l("Banner"), description=_l("Banner to use for the homepage. Optional.")
)
ctf_small_icon = FileField(
_l("Small Icon"),
description=_l(
"favicon used in user's browsers. Only PNGs accepted. Must be 32x32px. Optional."
),
)
ctf_theme = SelectField(
_l("Theme"),
description=_l("CTFd Theme to use. Can be changed later."),
choices=list(zip(get_themes(), get_themes())),
default=DEFAULT_THEME,
validators=[InputRequired()],
)
theme_color = HiddenField(
_l("Theme Color"),
description=_l(
"Color used by theme to control aesthetics. Requires theme support. Optional."
),
)
start = StringField(
_l("Start Time"),
description=_l("Time when your CTF is scheduled to start. Optional."),
)
end = StringField(
_l("End Time"),
description=_l("Time when your CTF is scheduled to end. Optional."),
)
submit = SubmitField(_l("Finish"))

View File

@@ -1,22 +0,0 @@
from wtforms import SelectField, StringField
from wtforms.validators import InputRequired
from CTFd.forms import BaseForm
from CTFd.forms.fields import SubmitField
class SubmissionSearchForm(BaseForm):
field = SelectField(
"Search Field",
choices=[
("provided", "Provided"),
("id", "ID"),
("account_id", "Account ID"),
("challenge_id", "Challenge ID"),
("challenge_name", "Challenge Name"),
],
default="provided",
validators=[InputRequired()],
)
q = StringField("Parameter", validators=[InputRequired()])
submit = SubmitField("Search")

View File

@@ -1,258 +0,0 @@
from flask_babel import lazy_gettext as _l
from wtforms import BooleanField, PasswordField, SelectField, StringField
from wtforms.fields.html5 import EmailField, URLField
from wtforms.validators import InputRequired
from CTFd.forms import BaseForm
from CTFd.forms.fields import SubmitField
from CTFd.models import TeamFieldEntries, TeamFields
from CTFd.utils.countries import SELECT_COUNTRIES_LIST
from CTFd.utils.user import get_current_team
def build_custom_team_fields(
form_cls,
include_entries=False,
fields_kwargs=None,
field_entries_kwargs=None,
blacklisted_items=("affiliation", "website"),
):
if fields_kwargs is None:
fields_kwargs = {}
if field_entries_kwargs is None:
field_entries_kwargs = {}
fields = []
new_fields = TeamFields.query.filter_by(**fields_kwargs).all()
user_fields = {}
# Only include preexisting values if asked
if include_entries is True:
for f in TeamFieldEntries.query.filter_by(**field_entries_kwargs).all():
user_fields[f.field_id] = f.value
for field in new_fields:
if field.name.lower() in blacklisted_items:
continue
form_field = getattr(form_cls, f"fields[{field.id}]")
# Add the field_type to the field so we know how to render it
form_field.field_type = field.field_type
# Only include preexisting values if asked
if include_entries is True:
initial = user_fields.get(field.id, "")
form_field.data = initial
if form_field.render_kw:
form_field.render_kw["data-initial"] = initial
else:
form_field.render_kw = {"data-initial": initial}
fields.append(form_field)
return fields
def attach_custom_team_fields(form_cls, **kwargs):
new_fields = TeamFields.query.filter_by(**kwargs).all()
for field in new_fields:
validators = []
if field.required:
validators.append(InputRequired())
if field.field_type == "text":
input_field = StringField(
field.name, description=field.description, validators=validators
)
elif field.field_type == "boolean":
input_field = BooleanField(
field.name, description=field.description, validators=validators
)
setattr(form_cls, f"fields[{field.id}]", input_field)
class TeamJoinForm(BaseForm):
name = StringField(_l("Team Name"), validators=[InputRequired()])
password = PasswordField(_l("Team Password"), validators=[InputRequired()])
submit = SubmitField(_l("Join"))
def TeamRegisterForm(*args, **kwargs):
class _TeamRegisterForm(BaseForm):
name = StringField(_l("Team Name"), validators=[InputRequired()])
password = PasswordField(_l("Team Password"), validators=[InputRequired()])
submit = SubmitField(_l("Create"))
@property
def extra(self):
return build_custom_team_fields(
self, include_entries=False, blacklisted_items=()
)
attach_custom_team_fields(_TeamRegisterForm)
return _TeamRegisterForm(*args, **kwargs)
def TeamSettingsForm(*args, **kwargs):
class _TeamSettingsForm(BaseForm):
name = StringField(
_l("Team Name"),
description=_l("Your team's public name shown to other competitors"),
)
password = PasswordField(
_l("New Team Password"), description=_l("Set a new team join password")
)
confirm = PasswordField(
_l("Confirm Current Team Password"),
description=_l(
"Provide your current team password (or your password) to update your team's password"
),
)
affiliation = StringField(
_l("Affiliation"),
description=_l(
"Your team's affiliation publicly shown to other competitors"
),
)
website = URLField(
_l("Website"),
description=_l("Your team's website publicly shown to other competitors"),
)
country = SelectField(
_l("Country"),
choices=SELECT_COUNTRIES_LIST,
description=_l("Your team's country publicly shown to other competitors"),
)
submit = SubmitField(_l("Submit"))
@property
def extra(self):
fields_kwargs = _TeamSettingsForm.get_field_kwargs()
return build_custom_team_fields(
self,
include_entries=True,
fields_kwargs=fields_kwargs,
field_entries_kwargs={"team_id": self.obj.id},
)
def get_field_kwargs():
team = get_current_team()
field_kwargs = {"editable": True}
if team.filled_all_required_fields is False:
# Show all fields
field_kwargs = {}
return field_kwargs
def __init__(self, *args, **kwargs):
"""
Custom init to persist the obj parameter to the rest of the form
"""
super().__init__(*args, **kwargs)
obj = kwargs.get("obj")
if obj:
self.obj = obj
field_kwargs = _TeamSettingsForm.get_field_kwargs()
attach_custom_team_fields(_TeamSettingsForm, **field_kwargs)
return _TeamSettingsForm(*args, **kwargs)
class TeamCaptainForm(BaseForm):
# Choices are populated dynamically at form creation time
captain_id = SelectField(
_l("Team Captain"), choices=[], validators=[InputRequired()]
)
submit = SubmitField("Submit")
class TeamSearchForm(BaseForm):
field = SelectField(
"Search Field",
choices=[
("name", "Name"),
("id", "ID"),
("affiliation", "Affiliation"),
("website", "Website"),
],
default="name",
validators=[InputRequired()],
)
q = StringField("Parameter", validators=[InputRequired()])
submit = SubmitField("Search")
class PublicTeamSearchForm(BaseForm):
field = SelectField(
_l("Search Field"),
choices=[
("name", _l("Name")),
("affiliation", _l("Affiliation")),
("website", _l("Website")),
],
default="name",
validators=[InputRequired()],
)
q = StringField(_l("Parameter"), validators=[InputRequired()])
submit = SubmitField(_l("Search"))
class TeamBaseForm(BaseForm):
name = StringField(_l("Team Name"), validators=[InputRequired()])
email = EmailField(_l("Email"))
password = PasswordField(_l("Password"))
website = URLField(_l("Website"))
affiliation = StringField(_l("Affiliation"))
country = SelectField(_l("Country"), choices=SELECT_COUNTRIES_LIST)
hidden = BooleanField(_l("Hidden"))
banned = BooleanField(_l("Banned"))
submit = SubmitField(_l("Submit"))
def TeamCreateForm(*args, **kwargs):
class _TeamCreateForm(TeamBaseForm):
pass
@property
def extra(self):
return build_custom_team_fields(self, include_entries=False)
attach_custom_team_fields(_TeamCreateForm)
return _TeamCreateForm(*args, **kwargs)
def TeamEditForm(*args, **kwargs):
class _TeamEditForm(TeamBaseForm):
pass
@property
def extra(self):
return build_custom_team_fields(
self,
include_entries=True,
fields_kwargs=None,
field_entries_kwargs={"team_id": self.obj.id},
)
def __init__(self, *args, **kwargs):
"""
Custom init to persist the obj parameter to the rest of the form
"""
super().__init__(*args, **kwargs)
obj = kwargs.get("obj")
if obj:
self.obj = obj
attach_custom_team_fields(_TeamEditForm)
return _TeamEditForm(*args, **kwargs)
class TeamInviteForm(BaseForm):
link = URLField(_l("Invite Link"))
class TeamInviteJoinForm(BaseForm):
submit = SubmitField(_l("Join"))

View File

@@ -1,204 +0,0 @@
from flask_babel import lazy_gettext as _l
from wtforms import BooleanField, PasswordField, SelectField, StringField
from wtforms.fields.html5 import EmailField
from wtforms.validators import InputRequired
from CTFd.constants.config import Configs
from CTFd.constants.languages import SELECT_LANGUAGE_LIST
from CTFd.forms import BaseForm
from CTFd.forms.fields import SubmitField
from CTFd.models import UserFieldEntries, UserFields
from CTFd.utils.countries import SELECT_COUNTRIES_LIST
def build_custom_user_fields(
form_cls,
include_entries=False,
fields_kwargs=None,
field_entries_kwargs=None,
blacklisted_items=("affiliation", "website"),
):
"""
Function used to reinject values back into forms for accessing by themes
"""
if fields_kwargs is None:
fields_kwargs = {}
if field_entries_kwargs is None:
field_entries_kwargs = {}
fields = []
new_fields = UserFields.query.filter_by(**fields_kwargs).all()
user_fields = {}
# Only include preexisting values if asked
if include_entries is True:
for f in UserFieldEntries.query.filter_by(**field_entries_kwargs).all():
user_fields[f.field_id] = f.value
for field in new_fields:
if field.name.lower() in blacklisted_items:
continue
form_field = getattr(form_cls, f"fields[{field.id}]")
# Add the field_type to the field so we know how to render it
form_field.field_type = field.field_type
# Only include preexisting values if asked
if include_entries is True:
initial = user_fields.get(field.id, "")
form_field.data = initial
if form_field.render_kw:
form_field.render_kw["data-initial"] = initial
else:
form_field.render_kw = {"data-initial": initial}
fields.append(form_field)
return fields
def attach_custom_user_fields(form_cls, **kwargs):
"""
Function used to attach form fields to wtforms.
Not really a great solution but is approved by wtforms.
https://wtforms.readthedocs.io/en/2.3.x/specific_problems/#dynamic-form-composition
"""
new_fields = UserFields.query.filter_by(**kwargs).all()
for field in new_fields:
validators = []
if field.required:
validators.append(InputRequired())
if field.field_type == "text":
input_field = StringField(
field.name, description=field.description, validators=validators
)
elif field.field_type == "boolean":
input_field = BooleanField(
field.name, description=field.description, validators=validators
)
setattr(form_cls, f"fields[{field.id}]", input_field)
def build_registration_code_field(form_cls):
"""
Build the appropriate field so we can render it via the extra property.
Add field_type so Jinja knows how to render it.
"""
if Configs.registration_code:
field = getattr(form_cls, "registration_code") # noqa B009
field.field_type = "text"
return [field]
else:
return []
def attach_registration_code_field(form_cls):
"""
If we have a registration code required, we attach it to the form similar
to attach_custom_user_fields
"""
if Configs.registration_code:
setattr( # noqa B010
form_cls,
"registration_code",
StringField(
"Registration Code",
description="Registration code required to create account",
validators=[InputRequired()],
),
)
class UserSearchForm(BaseForm):
field = SelectField(
"Search Field",
choices=[
("name", "Name"),
("id", "ID"),
("email", "Email"),
("affiliation", "Affiliation"),
("website", "Website"),
("ip", "IP Address"),
],
default="name",
validators=[InputRequired()],
)
q = StringField("Parameter", validators=[InputRequired()])
submit = SubmitField("Search")
class PublicUserSearchForm(BaseForm):
field = SelectField(
_l("Search Field"),
choices=[
("name", _l("Name")),
("affiliation", _l("Affiliation")),
("website", _l("Website")),
],
default="name",
validators=[InputRequired()],
)
q = StringField(
_l("Parameter"),
description=_l("Search for matching users"),
validators=[InputRequired()],
)
submit = SubmitField(_l("Search"))
class UserBaseForm(BaseForm):
name = StringField("User Name", validators=[InputRequired()])
email = EmailField("Email", validators=[InputRequired()])
language = SelectField(_l("Language"), choices=SELECT_LANGUAGE_LIST)
password = PasswordField("Password")
website = StringField("Website")
affiliation = StringField("Affiliation")
country = SelectField("Country", choices=SELECT_COUNTRIES_LIST)
type = SelectField("Type", choices=[("user", "User"), ("admin", "Admin")])
verified = BooleanField("Verified")
hidden = BooleanField("Hidden")
banned = BooleanField("Banned")
submit = SubmitField("Submit")
def UserEditForm(*args, **kwargs):
class _UserEditForm(UserBaseForm):
pass
@property
def extra(self):
return build_custom_user_fields(
self,
include_entries=True,
fields_kwargs=None,
field_entries_kwargs={"user_id": self.obj.id},
)
def __init__(self, *args, **kwargs):
"""
Custom init to persist the obj parameter to the rest of the form
"""
super().__init__(*args, **kwargs)
obj = kwargs.get("obj")
if obj:
self.obj = obj
attach_custom_user_fields(_UserEditForm)
return _UserEditForm(*args, **kwargs)
def UserCreateForm(*args, **kwargs):
class _UserCreateForm(UserBaseForm):
notify = BooleanField("Email account credentials to user", default=True)
@property
def extra(self):
return build_custom_user_fields(self, include_entries=False)
attach_custom_user_fields(_UserCreateForm)
return _UserCreateForm(*args, **kwargs)

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,209 +0,0 @@
import glob
import importlib
import os
from collections import namedtuple
from flask import current_app as app
from flask import send_file, send_from_directory, url_for
from CTFd.utils.config.pages import get_pages
from CTFd.utils.decorators import admins_only as admins_only_wrapper
from CTFd.utils.plugins import override_template as utils_override_template
from CTFd.utils.plugins import (
register_admin_script as utils_register_admin_plugin_script,
)
from CTFd.utils.plugins import (
register_admin_stylesheet as utils_register_admin_plugin_stylesheet,
)
from CTFd.utils.plugins import register_script as utils_register_plugin_script
from CTFd.utils.plugins import register_stylesheet as utils_register_plugin_stylesheet
Menu = namedtuple("Menu", ["title", "route"])
def register_plugin_assets_directory(app, base_path, admins_only=False, endpoint=None):
"""
Registers a directory to serve assets
:param app: A CTFd application
:param string base_path: The path to the directory
:param boolean admins_only: Whether or not the assets served out of the directory should be accessible to the public
:return:
"""
base_path = base_path.strip("/")
if endpoint is None:
endpoint = base_path.replace("/", ".")
def assets_handler(path):
return send_from_directory(base_path, path)
rule = "/" + base_path + "/<path:path>"
app.add_url_rule(rule=rule, endpoint=endpoint, view_func=assets_handler)
def register_plugin_asset(app, asset_path, admins_only=False, endpoint=None):
"""
Registers an file path to be served by CTFd
:param app: A CTFd application
:param string asset_path: The path to the asset file
:param boolean admins_only: Whether or not this file should be accessible to the public
:return:
"""
asset_path = asset_path.strip("/")
if endpoint is None:
endpoint = asset_path.replace("/", ".")
def asset_handler():
return send_file(asset_path)
if admins_only:
asset_handler = admins_only_wrapper(asset_handler)
rule = "/" + asset_path
app.add_url_rule(rule=rule, endpoint=endpoint, view_func=asset_handler)
def override_template(*args, **kwargs):
"""
Overrides a template with the provided html content.
e.g. override_template('scoreboard.html', '<h1>scores</h1>')
"""
utils_override_template(*args, **kwargs)
def register_plugin_script(*args, **kwargs):
"""
Adds a given script to the base.html template which all pages inherit from
"""
utils_register_plugin_script(*args, **kwargs)
def register_plugin_stylesheet(*args, **kwargs):
"""
Adds a given stylesheet to the base.html template which all pages inherit from.
"""
utils_register_plugin_stylesheet(*args, **kwargs)
def register_admin_plugin_script(*args, **kwargs):
"""
Adds a given script to the base.html of the admin theme which all admin pages inherit from
:param args:
:param kwargs:
:return:
"""
utils_register_admin_plugin_script(*args, **kwargs)
def register_admin_plugin_stylesheet(*args, **kwargs):
"""
Adds a given stylesheet to the base.html of the admin theme which all admin pages inherit from
:param args:
:param kwargs:
:return:
"""
utils_register_admin_plugin_stylesheet(*args, **kwargs)
def register_admin_plugin_menu_bar(title, route):
"""
Registers links on the Admin Panel menubar/navbar
:param name: A string that is shown on the navbar HTML
:param route: A string that is the href used by the link
:return:
"""
am = Menu(title=title, route=route)
app.admin_plugin_menu_bar.append(am)
def get_admin_plugin_menu_bar():
"""
Access the list used to store the plugin menu bar
:return: Returns a list of Menu namedtuples. They have name, and route attributes.
"""
return app.admin_plugin_menu_bar
def register_user_page_menu_bar(title, route):
"""
Registers links on the User side menubar/navbar
:param name: A string that is shown on the navbar HTML
:param route: A string that is the href used by the link
:return:
"""
p = Menu(title=title, route=route)
app.plugin_menu_bar.append(p)
def get_user_page_menu_bar():
"""
Access the list used to store the user page menu bar
:return: Returns a list of Menu namedtuples. They have name, and route attributes.
"""
pages = []
for p in get_pages() + app.plugin_menu_bar:
if p.route.startswith("http"):
route = p.route
else:
route = url_for("views.static_html", route=p.route)
pages.append(Menu(title=p.title, route=route))
return pages
def bypass_csrf_protection(f):
"""
Decorator that allows a route to bypass the need for a CSRF nonce on POST requests.
This should be considered beta and may change in future versions.
:param f: A function that needs to bypass CSRF protection
:return: Returns a function with the _bypass_csrf attribute set which tells CTFd to not require CSRF protection.
"""
f._bypass_csrf = True
return f
def get_plugin_names():
modules = sorted(glob.glob(app.plugins_dir + "/*"))
blacklist = {"__pycache__"}
plugins = []
for module in modules:
module_name = os.path.basename(module)
if os.path.isdir(module) and module_name not in blacklist:
plugins.append(module_name)
return plugins
def init_plugins(app):
"""
Searches for the load function in modules in the CTFd/plugins folder. This function is called with the current CTFd
app as a parameter. This allows CTFd plugins to modify CTFd's behavior.
:param app: A CTFd application
:return:
"""
app.admin_plugin_scripts = []
app.admin_plugin_stylesheets = []
app.plugin_scripts = []
app.plugin_stylesheets = []
app.admin_plugin_menu_bar = []
app.plugin_menu_bar = []
app.plugins_dir = os.path.dirname(__file__)
if app.config.get("SAFE_MODE", False) is False:
for plugin in get_plugin_names():
module = "." + plugin
module = importlib.import_module(module, package="CTFd.plugins")
module.load(app)
print(" * Loaded module, %s" % module)
else:
print("SAFE_MODE is enabled. Skipping plugin loading.")
app.jinja_env.globals.update(get_admin_plugin_menu_bar=get_admin_plugin_menu_bar)
app.jinja_env.globals.update(get_user_page_menu_bar=get_user_page_menu_bar)

View File

@@ -1,219 +0,0 @@
from flask import Blueprint
from CTFd.models import (
ChallengeFiles,
Challenges,
Fails,
Flags,
Hints,
Solves,
Tags,
db,
)
from CTFd.plugins import register_plugin_assets_directory
from CTFd.plugins.flags import FlagException, get_flag_class
from CTFd.utils.uploads import delete_file
from CTFd.utils.user import get_ip
class BaseChallenge(object):
id = None
name = None
templates = {}
scripts = {}
challenge_model = Challenges
@classmethod
def create(cls, request):
"""
This method is used to process the challenge creation request.
:param request:
:return:
"""
data = request.form or request.get_json()
challenge = cls.challenge_model(**data)
db.session.add(challenge)
db.session.commit()
return challenge
@classmethod
def read(cls, challenge):
"""
This method is in used to access the data of a challenge in a format processable by the front end.
:param challenge:
:return: Challenge object, data dictionary to be returned to the user
"""
data = {
"id": challenge.id,
"name": challenge.name,
"value": challenge.value,
"description": challenge.description,
"connection_info": challenge.connection_info,
"next_id": challenge.next_id,
"category": challenge.category,
"state": challenge.state,
"max_attempts": challenge.max_attempts,
"type": challenge.type,
"type_data": {
"id": cls.id,
"name": cls.name,
"templates": cls.templates,
"scripts": cls.scripts,
},
}
return data
@classmethod
def update(cls, challenge, request):
"""
This method is used to update the information associated with a challenge. This should be kept strictly to the
Challenges table and any child tables.
:param challenge:
:param request:
:return:
"""
data = request.form or request.get_json()
for attr, value in data.items():
setattr(challenge, attr, value)
db.session.commit()
return challenge
@classmethod
def delete(cls, challenge):
"""
This method is used to delete the resources used by a challenge.
:param challenge:
:return:
"""
Fails.query.filter_by(challenge_id=challenge.id).delete()
Solves.query.filter_by(challenge_id=challenge.id).delete()
Flags.query.filter_by(challenge_id=challenge.id).delete()
files = ChallengeFiles.query.filter_by(challenge_id=challenge.id).all()
for f in files:
delete_file(f.id)
ChallengeFiles.query.filter_by(challenge_id=challenge.id).delete()
Tags.query.filter_by(challenge_id=challenge.id).delete()
Hints.query.filter_by(challenge_id=challenge.id).delete()
Challenges.query.filter_by(id=challenge.id).delete()
cls.challenge_model.query.filter_by(id=challenge.id).delete()
db.session.commit()
@classmethod
def attempt(cls, challenge, request):
"""
This method is used to check whether a given input is right or wrong. It does not make any changes and should
return a boolean for correctness and a string to be shown to the user. It is also in charge of parsing the
user's input from the request itself.
:param challenge: The Challenge object from the database
:param request: The request the user submitted
:return: (boolean, string)
"""
data = request.form or request.get_json()
submission = data["submission"].strip()
flags = Flags.query.filter_by(challenge_id=challenge.id).all()
for flag in flags:
try:
if get_flag_class(flag.type).compare(flag, submission):
return True, "Correct"
except FlagException as e:
return False, str(e)
return False, "Incorrect"
@classmethod
def solve(cls, user, team, challenge, request):
"""
This method is used to insert Solves into the database in order to mark a challenge as solved.
:param team: The Team object from the database
:param chal: The Challenge object from the database
:param request: The request the user submitted
:return:
"""
data = request.form or request.get_json()
submission = data["submission"].strip()
solve = Solves(
user_id=user.id,
team_id=team.id if team else None,
challenge_id=challenge.id,
ip=get_ip(req=request),
provided=submission,
)
db.session.add(solve)
db.session.commit()
@classmethod
def fail(cls, user, team, challenge, request):
"""
This method is used to insert Fails into the database in order to mark an answer incorrect.
:param team: The Team object from the database
:param chal: The Challenge object from the database
:param request: The request the user submitted
:return:
"""
data = request.form or request.get_json()
submission = data["submission"].strip()
wrong = Fails(
user_id=user.id,
team_id=team.id if team else None,
challenge_id=challenge.id,
ip=get_ip(request),
provided=submission,
)
db.session.add(wrong)
db.session.commit()
class CTFdStandardChallenge(BaseChallenge):
id = "standard" # Unique identifier used to register challenges
name = "standard" # Name of a challenge type
templates = { # Templates used for each aspect of challenge editing & viewing
"create": "/plugins/challenges/assets/create.html",
"update": "/plugins/challenges/assets/update.html",
"view": "/plugins/challenges/assets/view.html",
}
scripts = { # Scripts that are loaded when a template is loaded
"create": "/plugins/challenges/assets/create.js",
"update": "/plugins/challenges/assets/update.js",
"view": "/plugins/challenges/assets/view.js",
}
# Route at which files are accessible. This must be registered using register_plugin_assets_directory()
route = "/plugins/challenges/assets/"
# Blueprint used to access the static_folder directory.
blueprint = Blueprint(
"standard", __name__, template_folder="templates", static_folder="assets"
)
challenge_model = Challenges
def get_chal_class(class_id):
"""
Utility function used to get the corresponding class from a class ID.
:param class_id: String representing the class ID
:return: Challenge class
"""
cls = CHALLENGE_CLASSES.get(class_id)
if cls is None:
raise KeyError
return cls
"""
Global dictionary used to hold all the Challenge Type classes used by CTFd. Insert into this dictionary to register
your Challenge Type.
"""
CHALLENGE_CLASSES = {"standard": CTFdStandardChallenge}
def load(app):
register_plugin_assets_directory(app, base_path="/plugins/challenges/assets/")

View File

@@ -1 +0,0 @@
{% extends "admin/challenges/create.html" %}

View File

@@ -1,4 +0,0 @@
CTFd.plugin.run((_CTFd) => {
const $ = _CTFd.lib.$
const md = _CTFd.lib.markdown()
})

View File

@@ -1 +0,0 @@
{% extends "admin/challenges/update.html" %}

View File

@@ -1 +0,0 @@
{% extends "challenge.html" %}

View File

@@ -1,37 +0,0 @@
CTFd._internal.challenge.data = undefined;
// TODO: Remove in CTFd v4.0
CTFd._internal.challenge.renderer = null;
CTFd._internal.challenge.preRender = function() {};
// TODO: Remove in CTFd v4.0
CTFd._internal.challenge.render = null;
CTFd._internal.challenge.postRender = function() {};
CTFd._internal.challenge.submit = function(preview) {
var challenge_id = parseInt(CTFd.lib.$("#challenge-id").val());
var submission = CTFd.lib.$("#challenge-input").val();
var body = {
challenge_id: challenge_id,
submission: submission
};
var params = {};
if (preview) {
params["preview"] = true;
}
return CTFd.api.post_challenge_attempt(params, body).then(function(response) {
if (response.status === 429) {
// User was ratelimited but process response
return response;
}
if (response.status === 403) {
// User is not logged in or CTF is paused.
return response;
}
return response;
});
};

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