Alpha release of CTFd v3. 

# 3.0.0a1 / 2020-07-01

**General**

- CTFd is now Python 3 only
- Render markdown with the CommonMark spec provided by `cmarkgfm`
- Render markdown stripped of any malicious JavaScript or HTML.
  - This is a significant change from previous versions of CTFd where any HTML content from an admin was considered safe.
- Inject `Config`, `User`, `Team`, `Session`, and `Plugin` globals into Jinja
- User sessions no longer store any user-specific attributes.
  - Sessions only store the user's ID, CSRF nonce, and an hmac of the user's password
  - This allows for session invalidation on password changes
- The user facing side of CTFd now has user and team searching
- GeoIP support now available for converting IP addresses to guessed countries

**Admin Panel**

- Use EasyMDE as an improved description/text editor for Markdown enabled fields.
- Media Library button now integrated into EasyMDE enabled fields
- VueJS now used as the underlying implementation for the Media Library
- Fix setting theme color in Admin Panel
- Green outline border has been removed from the Admin Panel

**API**

- Significant overhauls in API documentation provided by Swagger UI and Swagger json
- Make almost all API endpoints provide filtering and searching capabilities
- Change `GET /api/v1/config/<config_key>` to return structured data according to ConfigSchema

**Themes**

- Themes now have access to the `Configs` global which provides wrapped access to `get_config`.
  - For example, `{{ Configs.ctf_name }}` instead of `get_ctf_name()` or `get_config('ctf_name')`
- Themes must now specify a `challenge.html` which control how a challenge should look.
- The main library for charts has been changed from Plotly to Apache ECharts.
- Forms have been moved into wtforms for easier form rendering inside of Jinja.
  - From Jinja you can access forms via the Forms global i.e. `{{ Forms }}`
  - This allows theme developers to more easily re-use a form without having to copy-paste HTML.
- Themes can now provide a theme settings JSON blob which can be injected into the theme with `{{ Configs.theme_settings }}`
- Core theme now includes the challenge ID in location hash identifiers to always refer the right challenge despite duplicate names

**Plugins**

- Challenge plugins have changed in structure to better allow integration with themes and prevent obtrusive Javascript/XSS.
  - Challenge rendering now uses `challenge.html` from the provided theme.
  - Accessing the challenge view content is now provided by `/api/v1/challenges/<challenge_id>` in the `view` section. This allows for HTML to be properly sanitized and rendered by the server allowing CTFd to remove client side Jinja rendering.
  - `challenge.html` now specifies what's required and what's rendered by the theme. This allows the challenge plugin to avoid having to deal with aspects of the challenge besides the description and input.
  - A more complete migration guide will be provided when CTFd v3 leaves beta
- Display current attempt count in challenge view when max attempts is enabled
- `get_standings()`, `get_team_stanadings()`, `get_user_standings()` now has a fields keyword argument that allows for specificying additional fields that SQLAlchemy should return when building the response set.
  - Useful for gathering additional data when building scoreboard pages
- Flags can now control the message that is shown to the user by raising `FlagException`
- Fix `override_template()` functionality

**Deployment**

- Enable SQLAlchemy's `pool_pre_ping` by default to reduce the likelihood of database connection issues
- Mailgun email settings are now deprecated. Admins should move to SMTP email settings instead.
- Postgres is now considered a second class citizen in CTFd. It is tested against but not a main database backend. If you use Postgres, you are entirely on your own with regards to supporting CTFd.
- Docker image now uses Debian instead of Alpine. See https://github.com/CTFd/CTFd/issues/1215 for rationale.
- `docker-compose.yml` now uses a non-root user to connect to MySQL/MariaDB
- `config.py` should no longer be editting for configuration, instead edit `config.ini` or the environment variables in `docker-compose.yml`
This commit is contained in:
Kevin Chung
2020-07-01 12:06:05 -04:00
committed by GitHub
parent 9ca6270752
commit adc70fb320
298 changed files with 8789 additions and 5107 deletions

View File

@@ -13,5 +13,6 @@ module.exports = {
"sourceType": "module"
},
"rules": {
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
}
};

View File

@@ -6,9 +6,9 @@ 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:
- CTFd Version/Commit:
- Operating System:
- Web Browser and Version:
**What happened?**
@@ -17,4 +17,3 @@ If this is a feature request please describe the behavior that you'd like to see
**How to reproduce your issue**
**Any associated stack traces or error logs**

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

@@ -0,0 +1,43 @@
---
name: Linting
on: [push]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.6']
TESTING_DATABASE_URL: ['sqlite://']
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
- 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

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

@@ -0,0 +1,49 @@
---
name: CTFd MySQL CI
on: [pull_request]
jobs:
build:
runs-on: ubuntu-latest
services:
mysql:
image: mysql
ports:
- 3306:3306
redis:
image: redis
ports:
- 6379:6379
strategy:
matrix:
python-version: ['3.6']
TESTING_DATABASE_URL: ['mysql+pymysql://root@localhost/ctfd']
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
- name: Coverage
run: codecov

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

@@ -0,0 +1,58 @@
---
name: CTFd Postgres CI
on: [pull_request]
jobs:
build:
runs-on: ubuntu-latest
services:
postgres:
image: postgres
ports:
- 5432:5432
env:
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_DB: ctfd
# 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.6']
TESTING_DATABASE_URL: ['postgres://postgres@localhost/ctfd']
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
- name: Coverage
run: codecov

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

@@ -0,0 +1,41 @@
---
name: CTFd SQLite CI
on: [pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.6']
TESTING_DATABASE_URL: ['sqlite://']
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
- name: Coverage
run: codecov

2
.gitignore vendored
View File

@@ -36,6 +36,7 @@ pip-delete-this-directory.txt
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
@@ -57,6 +58,7 @@ target/
*.db
*.log
*.log.*
.idea/
.vscode/
CTFd/static/uploads

7
.isort.cfg Normal file
View File

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

View File

@@ -1,11 +1,10 @@
language: python
dist: xenial
dist: bionic
cache:
- pip
- yarn
services:
- mysql
- postgresql
- redis-server
addons:
apt:
@@ -17,21 +16,16 @@ addons:
env:
- TESTING_DATABASE_URL='mysql+pymysql://root@localhost/ctfd'
- TESTING_DATABASE_URL='sqlite://'
- TESTING_DATABASE_URL='postgres://postgres@localhost/ctfd'
python:
- 2.7
- 3.6
before_install:
- sudo rm -f /etc/boto.cfg
- export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
- export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
- python3.6 -m pip install black==19.3b0
install:
- pip install -r development.txt
- yarn install --non-interactive
- yarn global add prettier@1.17.0
before_script:
- psql -c 'create database ctfd;' -U postgres
script:
- make lint
- make test

File diff suppressed because it is too large Load Diff

View File

@@ -2,19 +2,19 @@
#### **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).
- **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).
- **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).
- 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.
- 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 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.
- 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?**

View File

@@ -4,11 +4,11 @@ import sys
import weakref
from distutils.version import StrictVersion
import jinja2
from flask import Flask, Request
from flask_migrate import upgrade
from jinja2 import FileSystemLoader
from jinja2.sandbox import SandboxedEnvironment
from six.moves import input
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.utils import cached_property
@@ -26,12 +26,7 @@ from CTFd.utils.migrations import create_database, migrations, stamp_latest_revi
from CTFd.utils.sessions import CachingSessionInterface
from CTFd.utils.updates import update_check
# Hack to support Unicode in Python 2 properly
if sys.version_info[0] < 3:
reload(sys) # noqa: F821
sys.setdefaultencoding("utf-8")
__version__ = "2.5.0"
__version__ = "3.0.0a1"
class CTFdRequest(Request):
@@ -129,7 +124,7 @@ def confirm_upgrade():
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":
if input("Run database migrations (Y/N)").lower().strip() == "y": # nosec B322
return True
else:
print("/*\\ Ignored database migrations... /*\\")
@@ -148,10 +143,19 @@ def create_app(config="CTFd.config.Config"):
with app.app_context():
app.config.from_object(config)
theme_loader = ThemeLoader(
app.theme_loader = ThemeLoader(
os.path.join(app.root_path, "themes"), followlinks=True
)
app.jinja_loader = theme_loader
# Weird nested solution for accessing plugin templates
app.plugin_loader = jinja2.PrefixLoader(
{
"plugins": jinja2.FileSystemLoader(
searchpath=os.path.join(app.root_path, "plugins"), followlinks=True
)
}
)
# Load from themes first but fallback to loading from the plugin folder
app.jinja_loader = jinja2.ChoiceLoader([app.theme_loader, app.plugin_loader])
from CTFd.models import ( # noqa: F401
db,
@@ -215,16 +219,10 @@ def create_app(config="CTFd.config.Config"):
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, None, *proxyfix_args)
app.wsgi_app = ProxyFix(app.wsgi_app, *proxyfix_args)
else:
app.wsgi_app = ProxyFix(
app.wsgi_app,
num_proxies=None,
x_for=1,
x_proto=1,
x_host=1,
x_port=1,
x_prefix=1,
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1
)
version = utils.get_config("ctf_version")

View File

@@ -1,8 +1,8 @@
import csv
import datetime
import os
from io import BytesIO, StringIO
import six
from flask import Blueprint, abort
from flask import current_app as app
from flask import (
@@ -14,7 +14,18 @@ from flask import (
url_for,
)
from CTFd.cache import cache, clear_config, clear_standings, clear_pages
admin = Blueprint("admin", __name__)
# isort:imports-firstparty
from CTFd.admin import challenges # noqa: F401
from CTFd.admin import notifications # noqa: F401
from CTFd.admin import pages # noqa: F401
from CTFd.admin import scoreboard # noqa: F401
from CTFd.admin import statistics # noqa: F401
from CTFd.admin import submissions # noqa: F401
from CTFd.admin import teams # noqa: F401
from CTFd.admin import users # noqa: F401
from CTFd.cache import cache, clear_config, clear_pages, clear_standings
from CTFd.models import (
Awards,
Challenges,
@@ -40,17 +51,6 @@ from CTFd.utils.security.auth import logout_user
from CTFd.utils.uploads import delete_file
from CTFd.utils.user import is_admin
admin = Blueprint("admin", __name__)
from CTFd.admin import challenges # noqa: F401
from CTFd.admin import notifications # noqa: F401
from CTFd.admin import pages # noqa: F401
from CTFd.admin import scoreboard # noqa: F401
from CTFd.admin import statistics # noqa: F401
from CTFd.admin import submissions # noqa: F401
from CTFd.admin import teams # noqa: F401
from CTFd.admin import users # noqa: F401
@admin.route("/admin", methods=["GET"])
def view():
@@ -126,7 +126,7 @@ def export_csv():
if model is None:
abort(404)
temp = six.StringIO()
temp = StringIO()
writer = csv.writer(temp)
header = [column.name for column in model.__mapper__.columns]
@@ -142,7 +142,7 @@ def export_csv():
temp.seek(0)
# In Python 3 send_file requires bytes
output = six.BytesIO()
output = BytesIO()
output.write(temp.getvalue().encode("utf-8"))
output.seek(0)
temp.close()
@@ -163,17 +163,13 @@ def config():
# Clear the config cache so that we don't get stale values
clear_config()
database_tables = sorted(db.metadata.tables.keys())
configs = Configs.query.all()
configs = dict([(c.key, get_config(c.key)) for c in configs])
themes = ctf_config.get_themes()
themes.remove(get_config("ctf_theme"))
return render_template(
"admin/config.html", database_tables=database_tables, themes=themes, **configs
)
return render_template("admin/config.html", themes=themes, **configs)
@admin.route("/admin/reset", methods=["GET", "POST"])

View File

@@ -1,13 +1,8 @@
import os
import six
from flask import current_app as app
from flask import render_template, render_template_string, request, url_for
from flask import render_template, request, url_for
from CTFd.admin import admin
from CTFd.models import Challenges, Flags, Solves
from CTFd.plugins.challenges import get_chal_class
from CTFd.utils import binary_type
from CTFd.utils.decorators import admins_only
@@ -51,14 +46,9 @@ def challenges_detail(challenge_id):
flags = Flags.query.filter_by(challenge_id=challenge.id).all()
challenge_class = get_chal_class(challenge.type)
with open(
os.path.join(app.root_path, challenge_class.templates["update"].lstrip("/")),
"rb",
) as update:
tpl = update.read()
if six.PY3 and isinstance(tpl, binary_type):
tpl = tpl.decode("utf-8")
update_j2 = render_template_string(tpl, challenge=challenge)
update_j2 = render_template(
challenge_class.templates["update"].lstrip("/"), challenge=challenge
)
update_script = url_for(
"views.static_html", route=challenge_class.scripts["update"].lstrip("/")

View File

@@ -4,6 +4,7 @@ from CTFd.admin import admin
from CTFd.models import Pages
from CTFd.schemas.pages import PageSchema
from CTFd.utils import markdown
from CTFd.utils.config.pages import build_html
from CTFd.utils.decorators import admins_only
@@ -26,7 +27,7 @@ def pages_preview():
data = request.form.to_dict()
schema = PageSchema()
page = schema.load(data)
return render_template("page.html", content=markdown(page.data.content))
return render_template("page.html", content=build_html(page.data.content))
@admin.route("/admin/pages/<int:page_id>")

View File

@@ -9,6 +9,11 @@ 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
@@ -19,7 +24,13 @@ 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"))
CTFd_API_v1 = Api(api, version="v1", doc=current_app.config.get("SWAGGER_UI_ENDPOINT"))
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")

View File

@@ -1,18 +1,103 @@
from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.models import build_model_filters
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.utils.config import is_teams_mode
from CTFd.models import Awards, db, Users
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
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()
@@ -57,6 +142,16 @@ class AwardList(Resource):
@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)
@@ -66,6 +161,10 @@ class Award(Resource):
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)

View File

@@ -1,12 +1,28 @@
import datetime
from typing import List
from flask import abort, request, url_for
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.models import build_model_filters
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 ChallengeFiles as ChallengeFilesModel
from CTFd.models import Challenges, Fails, Flags, Hints, HintUnlocks, Solves, Tags, db
from CTFd.models import (
Challenges,
Fails,
Flags,
Hints,
HintUnlocks,
Solves,
Submissions,
Tags,
db,
)
from CTFd.plugins.challenges import CHALLENGE_CLASSES, get_chal_class
from CTFd.schemas.flags import FlagSchema
from CTFd.schemas.hints import HintSchema
@@ -37,25 +53,92 @@ challenges_namespace = Namespace(
"challenges", description="Endpoint to retrieve Challenges"
)
ChallengeModel = sqlalchemy_to_pydantic(Challenges)
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
def get(self):
@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):
# Build filtering queries
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
filters = build_model_filters(model=Challenges, query=q, field=field)
# This can return None (unauth) if visibility is set to public
user = get_current_user()
# Admins can request to see everything
if is_admin() and request.args.get("view") == "admin":
challenges = Challenges.query.order_by(Challenges.value).all()
challenges = (
Challenges.query.filter_by(**query_args)
.filter(*filters)
.order_by(Challenges.value)
.all()
)
solve_ids = set([challenge.id for challenge in challenges])
else:
challenges = (
Challenges.query.filter(
and_(Challenges.state != "hidden", Challenges.state != "locked")
)
.filter_by(**query_args)
.filter(*filters)
.order_by(Challenges.value)
.all()
)
@@ -122,6 +205,16 @@ class ChallengeList(Resource):
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()
challenge_type = data["type"]
@@ -144,16 +237,28 @@ class ChallengeTypes(Resource):
"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>")
@challenges_namespace.param("challenge_id", "A 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()
@@ -270,14 +375,44 @@ class Challenge(Resource):
else:
response["solves"] = 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["attempts"] = attempts
response["files"] = files
response["tags"] = tags
response["hints"] = hints
response["view"] = render_template(
chal_class.templates["view"].lstrip("/"),
solves=solves,
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):
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
challenge_class = get_chal_class(challenge.type)
@@ -286,6 +421,10 @@ class Challenge(Resource):
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)
@@ -496,7 +635,6 @@ class ChallengeAttempt(Resource):
@challenges_namespace.route("/<challenge_id>/solves")
@challenges_namespace.param("id", "A Challenge ID")
class ChallengeSolves(Resource):
@check_challenge_visibility
@check_score_visibility
@@ -544,7 +682,6 @@ class ChallengeSolves(Resource):
@challenges_namespace.route("/<challenge_id>/files")
@challenges_namespace.param("id", "A Challenge ID")
class ChallengeFiles(Resource):
@admins_only
def get(self, challenge_id):
@@ -560,7 +697,6 @@ class ChallengeFiles(Resource):
@challenges_namespace.route("/<challenge_id>/tags")
@challenges_namespace.param("id", "A Challenge ID")
class ChallengeTags(Resource):
@admins_only
def get(self, challenge_id):
@@ -576,7 +712,6 @@ class ChallengeTags(Resource):
@challenges_namespace.route("/<challenge_id>/hints")
@challenges_namespace.param("id", "A Challenge ID")
class ChallengeHints(Resource):
@admins_only
def get(self, challenge_id):
@@ -591,7 +726,6 @@ class ChallengeHints(Resource):
@challenges_namespace.route("/<challenge_id>/flags")
@challenges_namespace.param("id", "A Challenge ID")
class ChallengeFlags(Resource):
@admins_only
def get(self, challenge_id):

View File

@@ -1,20 +1,69 @@
from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.models import build_model_filters
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_config, clear_standings
from CTFd.constants import RawEnum
from CTFd.models import Configs, db
from CTFd.schemas.config import ConfigSchema
from CTFd.utils import get_config, set_config
from CTFd.utils import set_config
from CTFd.utils.decorators import admins_only
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
def get(self):
configs = Configs.query.all()
@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:
@@ -23,6 +72,16 @@ class ConfigList(Resource):
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()
@@ -43,6 +102,10 @@ class ConfigList(Resource):
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()
@@ -58,11 +121,33 @@ class ConfigList(Resource):
@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):
return {"success": True, "data": get_config(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()
@@ -89,6 +174,10 @@ class Config(Resource):
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()

View File

@@ -1,6 +1,13 @@
from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.models import build_model_filters
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
@@ -8,13 +15,57 @@ from CTFd.utils.decorators import admins_only
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
def get(self):
file_type = request.args.get("type")
files = Files.query.filter_by(type=file_type).all()
@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)
@@ -24,6 +75,16 @@ class FilesList(Resource):
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
@@ -47,6 +108,16 @@ class FilesList(Resource):
@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()
@@ -58,6 +129,10 @@ class FilesDetail(Resource):
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()

View File

@@ -1,6 +1,13 @@
from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.models import build_model_filters
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
@@ -8,12 +15,61 @@ from CTFd.utils.decorators import admins_only
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
def get(self):
flags = Flags.query.all()
@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:
@@ -22,6 +78,16 @@ class FlagList(Resource):
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()
@@ -62,6 +128,16 @@ class FlagTypes(Resource):
@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()
@@ -75,6 +151,10 @@ class Flag(Resource):
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()
@@ -85,6 +165,16 @@ class Flag(Resource):
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()

View File

View File

@@ -0,0 +1,7 @@
def build_model_filters(model, query, field):
filters = []
if query:
# The field exists as an exposed column
if model.__mapper__.has_property(field):
filters.append(getattr(model, field).like("%{}%".format(query)))
return filters

View File

@@ -0,0 +1,49 @@
from functools import wraps
from flask import request
from pydantic import 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]()
loaded = spec(**data).dict(exclude_unset=True)
return func(*args, loaded, **kwargs)
return wrapper
return decorator

View File

@@ -0,0 +1,31 @@
from typing import Container, 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, *, exclude: Container[str] = []
) -> Type[BaseModel]:
"""
Mostly copied from https://github.com/tiangolo/pydantic-sqlalchemy
"""
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)
pydantic_model = create_model(
db_model.__name__, **fields # type: ignore
)
return pydantic_model

View File

@@ -1,6 +1,13 @@
from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.models import build_model_filters
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
@@ -8,12 +15,59 @@ 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
def get(self):
hints = Hints.query.all()
@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).dump(hints)
if response.errors:
@@ -22,6 +76,16 @@ class HintList(Resource):
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("admin")
@@ -42,6 +106,16 @@ class HintList(Resource):
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()
@@ -67,6 +141,16 @@ class Hint(Resource):
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()
@@ -85,6 +169,10 @@ class Hint(Resource):
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)

View File

@@ -1,6 +1,13 @@
from typing import List
from flask import current_app, request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.models import build_model_filters
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
@@ -9,11 +16,61 @@ 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):
def get(self):
notifications = Notifications.query.all()
@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,
),
},
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)
notifications = (
Notifications.query.filter_by(**query_args).filter(*filters).all()
)
schema = NotificationSchema(many=True)
result = schema.dump(notifications)
if result.errors:
@@ -21,6 +78,16 @@ class NotificantionList(Resource):
return {"success": True, "data": result.data}
@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()
@@ -49,6 +116,16 @@ class NotificantionList(Resource):
@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()
@@ -59,6 +136,10 @@ class Notification(Resource):
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)

View File

@@ -1,7 +1,14 @@
from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.models import build_model_filters
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
@@ -9,11 +16,68 @@ from CTFd.utils.decorators import admins_only
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
def get(self):
pages = Pages.query.all()
@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:
@@ -22,8 +86,19 @@ class PageList(Resource):
return {"success": True, "data": response.data}
@admins_only
def post(self):
req = request.get_json()
@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)
@@ -42,8 +117,19 @@ class PageList(Resource):
@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()
@@ -55,6 +141,7 @@ class PageDetail(Resource):
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()
@@ -75,6 +162,10 @@ class PageDetail(Resource):
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)

View File

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

View File

@@ -1,7 +1,6 @@
from collections import defaultdict
from flask_restx import Namespace, Resource
from sqlalchemy.orm import joinedload
from CTFd.cache import cache, make_cache_key

View File

@@ -4,8 +4,9 @@ statistics_namespace = Namespace(
"statistics", description="Endpoint to retrieve Statistics"
)
# isort:imports-firstparty
from CTFd.api.v1.statistics import challenges # noqa: F401
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
from CTFd.api.v1.statistics import scores # noqa: F401

View File

@@ -3,7 +3,7 @@ from collections import defaultdict
from flask_restx import Resource
from CTFd.api.v1.statistics import statistics_namespace
from CTFd.models import db, Challenges
from CTFd.models import Challenges, db
from CTFd.utils.decorators import admins_only
from CTFd.utils.scores import get_standings

View File

@@ -1,7 +1,16 @@
from flask import request
from typing import List
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.models import build_model_filters
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_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
@@ -10,28 +19,114 @@ 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
def get(self):
args = request.args.to_dict()
schema = SubmissionSchema(many=True)
if args:
submissions = Submissions.query.filter_by(**args).all()
else:
submissions = Submissions.query.all()
@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)
response = schema.dump(submissions)
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 {"success": True, "data": response.data}
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
def post(self):
req = request.get_json()
@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)
@@ -54,6 +149,16 @@ class SubmissionsList(Resource):
@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()
@@ -65,6 +170,16 @@ class Submission(Resource):
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)

View File

@@ -1,19 +1,70 @@
from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.models import build_model_filters
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
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
def get(self):
# TODO: Filter by challenge_id
tags = Tags.query.all()
@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)
@@ -23,6 +74,16 @@ class TagList(Resource):
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()
@@ -44,6 +105,16 @@ class TagList(Resource):
@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()
@@ -55,6 +126,16 @@ class Tag(Resource):
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()
@@ -72,6 +153,10 @@ class Tag(Resource):
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)

View File

@@ -1,9 +1,18 @@
import copy
from typing import List
from flask import abort, request, session
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.models import build_model_filters
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_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
@@ -17,27 +26,114 @@ 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):
@check_account_visibility
def get(self):
@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()
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)
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)
response = TeamSchema(view=view, many=True).dump(teams.items)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
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()
@@ -63,6 +159,16 @@ class TeamList(Resource):
@teams_namespace.param("team_id", "Team ID")
class TeamPublic(Resource):
@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()
@@ -82,6 +188,16 @@ class TeamPublic(Resource):
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()
@@ -104,6 +220,10 @@ class TeamPublic(Resource):
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
@@ -128,6 +248,16 @@ class TeamPublic(Resource):
class TeamPrivate(Resource):
@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)
@@ -141,6 +271,16 @@ class TeamPrivate(Resource):
@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"]:

View File

@@ -1,8 +1,11 @@
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
@@ -11,11 +14,50 @@ 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)
@@ -30,6 +72,16 @@ class TokenList(Resource):
@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")
@@ -54,6 +106,16 @@ class TokenList(Resource):
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()
@@ -73,6 +135,10 @@ class TokenDetail(Resource):
@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()

View File

@@ -1,7 +1,14 @@
from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.models import build_model_filters
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
@@ -15,14 +22,62 @@ 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
def get(self):
hints = Unlocks.query.all()
@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(hints)
response = schema.dump(unlocks)
if response.errors:
return {"success": False, "errors": response.errors}, 400
@@ -32,6 +87,16 @@ class UnlockList(Resource):
@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()

View File

@@ -1,7 +1,17 @@
from typing import List
from flask import abort, request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.models import build_model_filters
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_standings, clear_user_session
from CTFd.constants import RawEnum
from CTFd.models import (
Awards,
Notifications,
@@ -28,28 +38,115 @@ 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
def get(self):
if is_admin() and request.args.get("view") == "admin":
users = Users.query.filter_by()
else:
users = Users.query.filter_by(banned=False, hidden=False)
@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)
response = UserSchema(view="user", many=True).dump(users)
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 {"success": True, "data": response.data}
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,
}
@users_namespace.doc()
@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"
}
},
)
@admins_only
def post(self):
req = request.get_json()
schema = UserSchema("admin")
@@ -79,6 +176,16 @@ class UserList(Resource):
@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()
@@ -97,6 +204,16 @@ class UserPublic(Resource):
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()
@@ -118,6 +235,10 @@ class UserPublic(Resource):
return {"success": True, "data": response}
@admins_only
@users_namespace.doc(
description="Endpoint to delete a specific User object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, user_id):
Notifications.query.filter_by(user_id=user_id).delete()
Awards.query.filter_by(user_id=user_id).delete()
@@ -138,6 +259,16 @@ class UserPublic(Resource):
@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
@@ -146,6 +277,16 @@ class UserPrivate(Resource):
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()
@@ -294,6 +435,10 @@ class UserPublicAwards(Resource):
@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()
@@ -314,4 +459,4 @@ class UserEmails(Resource):
result, response = sendmail(addr=user.email, text=text)
return {"success": result, "data": {}}
return {"success": result}

View File

@@ -6,10 +6,10 @@ 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, Users, db
from CTFd.utils import config, email, get_app_config, get_config
from CTFd.utils import user as current_user
from CTFd.cache import clear_user_session, clear_team_session
from CTFd.utils import validators
from CTFd.utils.config import is_teams_mode
from CTFd.utils.config.integrations import mlc_registration
@@ -17,7 +17,7 @@ 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
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
@@ -66,7 +66,7 @@ def confirm(data=None):
return redirect(url_for("auth.login"))
# User is trying to start or restart the confirmation flow
if not current_user.authed():
if current_user.authed() is False:
return redirect(url_for("auth.login"))
user = Users.query.filter_by(id=session["id"]).first_or_404()
@@ -82,19 +82,27 @@ def confirm(data=None):
format="[{date}] {ip} - {name} initiated a confirmation email resend",
)
return render_template(
"confirm.html",
user=user,
infos=["Your confirmation email has been resent!"],
"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", user=user)
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)
@@ -115,7 +123,7 @@ def reset_password(data=None):
if user.oauth_id:
return render_template(
"reset_password.html",
errors=[
infos=[
"Your account was registered via an authentication provider and does not have an associated password. Please login via your authentication provider."
],
)
@@ -144,16 +152,10 @@ def reset_password(data=None):
get_errors()
if config.can_send_mail() is False:
return render_template(
"reset_password.html",
errors=["Email could not be sent due to server misconfiguration"],
)
if not user:
return render_template(
"reset_password.html",
errors=[
infos=[
"If that account exists you will receive an email, please check your inbox"
],
)
@@ -161,7 +163,7 @@ def reset_password(data=None):
if user.oauth_id:
return render_template(
"reset_password.html",
errors=[
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."
],
)
@@ -170,7 +172,7 @@ def reset_password(data=None):
return render_template(
"reset_password.html",
errors=[
infos=[
"If that account exists you will receive an email, please check your inbox"
],
)

View File

@@ -30,14 +30,31 @@ def clear_standings():
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="scoreboard.listing"))
cache.delete(make_cache_key(path=api.name + "." + ScoreboardList.endpoint))
cache.delete(make_cache_key(path=api.name + "." + ScoreboardDetail.endpoint))

View File

@@ -1,7 +1,7 @@
from flask import Blueprint, render_template
from CTFd.utils import config, get_config
from CTFd.utils.dates import ctf_ended, ctf_paused, view_after_ctf
from CTFd.utils import config
from CTFd.utils.dates import ctf_ended, ctf_paused, ctf_started
from CTFd.utils.decorators import (
during_ctf_time_only,
require_team,
@@ -21,16 +21,14 @@ challenges = Blueprint("challenges", __name__)
def listing():
infos = get_infos()
errors = get_errors()
start = get_config("start") or 0
end = get_config("end") or 0
if ctf_paused():
infos.append("{} is paused".format(config.ctf_name()))
if ctf_started() is False:
errors.append(f"{config.ctf_name()} has not started yet")
# CTF has ended but we want to allow view_after_ctf. Show error but let JS load challenges.
if ctf_ended() and view_after_ctf():
infos.append("{} has ended".format(config.ctf_name()))
if ctf_paused() is True:
infos.append(f"{config.ctf_name()} is paused")
return render_template(
"challenges.html", infos=infos, errors=errors, start=int(start), end=int(end)
)
if ctf_ended() is True:
infos.append(f"{config.ctf_name()} has ended")
return render_template("challenges.html", infos=infos, errors=errors)

48
CTFd/config.ini Normal file
View File

@@ -0,0 +1,48 @@
[server]
SECRET_KEY =
DATABASE_URL =
REDIS_URL =
[security]
SESSION_COOKIE_HTTPONLY = true
SESSION_COOKIE_SAMESITE = Lax
PERMANENT_SESSION_LIFETIME = 604800
TRUSTED_PROXIES =
[email]
MAILFROM_ADDR =
MAIL_SERVER =
MAIL_PORT =
MAIL_USEAUTH =
MAIL_USERNAME =
MAIL_PASSWORD =
MAIL_TLS =
MAIL_SSL =
MAILGUN_API_KEY =
MAILGUN_BASE_URL =
[uploads]
UPLOAD_PROVIDER = filesystem
UPLOAD_FOLDER =
AWS_ACCESS_KEY_ID =
AWS_SECRET_ACCESS_KEY =
AWS_S3_BUCKET =
AWS_S3_ENDPOINT_URL =
[logs]
LOG_FOLDER =
[optional]
REVERSE_PROXY =
TEMPLATES_AUTO_RELOAD =
SQLALCHEMY_TRACK_MODIFICATIONS =
SWAGGER_UI =
UPDATE_CHECK =
APPLICATION_ROOT =
SERVER_SENT_EVENTS =
SQLALCHEMY_MAX_OVERFLOW =
SQLALCHEMY_POOL_PRE_PING =
[oauth]
OAUTH_CLIENT_ID =
OAUTH_CLIENT_SECRET =

View File

@@ -1,8 +1,28 @@
import configparser
import os
from distutils.util import strtobool
""" GENERATE SECRET KEY """
if not os.getenv("SECRET_KEY"):
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:
@@ -21,11 +41,15 @@ if not os.getenv("SECRET_KEY"):
secret.flush()
except (OSError, IOError):
pass
return key
""" SERVER SETTINGS """
config_ini = configparser.ConfigParser()
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.ini")
config_ini.read(path)
# fmt: off
class Config(object):
"""
CTFd Configuration Object
@@ -62,33 +86,37 @@ class Config(object):
e.g. redis://user:password@localhost:6379
http://pythonhosted.org/Flask-Caching/#configuring-flask-caching
"""
SECRET_KEY = os.getenv("SECRET_KEY") or key
DATABASE_URL = os.getenv("DATABASE_URL") or "sqlite:///{}/ctfd.db".format(
os.path.dirname(os.path.abspath(__file__))
)
REDIS_URL = os.getenv("REDIS_URL")
SECRET_KEY: str = os.getenv("SECRET_KEY") \
or empty_str_cast(config_ini["server"]["SECRET_KEY"]) \
or gen_secret_key()
DATABASE_URL: str = os.getenv("DATABASE_URL") \
or empty_str_cast(config_ini["server"]["DATABASE_URL"]) \
or f"sqlite:///{os.path.dirname(os.path.abspath(__file__))}/ctfd.db"
REDIS_URL: str = os.getenv("REDIS_URL") \
or empty_str_cast(config_ini["server"]["REDIS_URL"])
SQLALCHEMY_DATABASE_URI = DATABASE_URL
CACHE_REDIS_URL = REDIS_URL
if CACHE_REDIS_URL:
CACHE_TYPE = "redis"
CACHE_TYPE: str = "redis"
else:
CACHE_TYPE = "filesystem"
CACHE_DIR = os.path.join(
CACHE_TYPE: str = "filesystem"
CACHE_DIR: str = os.path.join(
os.path.dirname(__file__), os.pardir, ".data", "filesystem_cache"
)
CACHE_THRESHOLD = (
0
) # Override the threshold of cached values on the filesystem. The default is 500. Don't change unless you know what you're doing.
# 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:
Controls if cookies should be set with the HttpOnly flag.
Controls if cookies should be set with the HttpOnly flag. Defaults to True
PERMANENT_SESSION_LIFETIME:
The lifetime of a session. The default is 604800 seconds.
The lifetime of a session. The default is 604800 seconds (7 days).
TRUSTED_PROXIES:
Defines a set of regular expressions used for finding a user's IP address if the CTFd instance
@@ -98,11 +126,18 @@ class Config(object):
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.
"""
SESSION_COOKIE_HTTPONLY = not os.getenv("SESSION_COOKIE_HTTPONLY") # Defaults True
SESSION_COOKIE_SAMESITE = os.getenv("SESSION_COOKIE_SAMESITE") or "Lax"
PERMANENT_SESSION_LIFETIME = int(
os.getenv("PERMANENT_SESSION_LIFETIME") or 604800
) # 7 days in seconds
SESSION_COOKIE_HTTPONLY: bool = process_boolean_str(os.getenv("SESSION_COOKIE_HTTPONLY")) \
or config_ini["security"].getboolean("SESSION_COOKIE_HTTPONLY") \
or True
SESSION_COOKIE_SAMESITE: str = os.getenv("SESSION_COOKIE_SAMESITE") \
or empty_str_cast(config_ini["security"]["SESSION_COOKIE_SAMESITE"]) \
or "Lax"
PERMANENT_SESSION_LIFETIME: int = int(os.getenv("PERMANENT_SESSION_LIFETIME", 0)) \
or config_ini["security"].getint("PERMANENT_SESSION_LIFETIME") \
or 604800
TRUSTED_PROXIES = [
r"^127\.0\.0\.1$",
# Remove the following proxies if you do not trust the local network
@@ -143,21 +178,43 @@ class Config(object):
Whether to connect to the SMTP server over SSL
MAILGUN_API_KEY
Mailgun API key to send email over Mailgun
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_BASE_URL
Mailgun base url to send email over Mailgun
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.
"""
MAILFROM_ADDR = os.getenv("MAILFROM_ADDR") or "noreply@ctfd.io"
MAIL_SERVER = os.getenv("MAIL_SERVER") or None
MAIL_PORT = os.getenv("MAIL_PORT")
MAIL_USEAUTH = os.getenv("MAIL_USEAUTH")
MAIL_USERNAME = os.getenv("MAIL_USERNAME")
MAIL_PASSWORD = os.getenv("MAIL_PASSWORD")
MAIL_TLS = os.getenv("MAIL_TLS") or False
MAIL_SSL = os.getenv("MAIL_SSL") or False
MAILGUN_API_KEY = os.getenv("MAILGUN_API_KEY")
MAILGUN_BASE_URL = os.getenv("MAILGUN_BASE_URL")
MAILFROM_ADDR: str = os.getenv("MAILFROM_ADDR") \
or config_ini["email"]["MAILFROM_ADDR"] \
or "noreply@ctfd.io"
MAIL_SERVER: str = os.getenv("MAIL_SERVER") \
or empty_str_cast(config_ini["email"]["MAIL_SERVER"])
MAIL_PORT: str = os.getenv("MAIL_PORT") \
or empty_str_cast(config_ini["email"]["MAIL_PORT"])
MAIL_USEAUTH: bool = process_boolean_str(os.getenv("MAIL_USEAUTH")) \
or process_boolean_str(config_ini["email"]["MAIL_USEAUTH"])
MAIL_USERNAME: str = os.getenv("MAIL_USERNAME") \
or empty_str_cast(config_ini["email"]["MAIL_USERNAME"])
MAIL_PASSWORD: str = os.getenv("MAIL_PASSWORD") \
or empty_str_cast(config_ini["email"]["MAIL_PASSWORD"])
MAIL_TLS: bool = process_boolean_str(os.getenv("MAIL_TLS")) \
or process_boolean_str(config_ini["email"]["MAIL_TLS"])
MAIL_SSL: bool = process_boolean_str(os.getenv("MAIL_SSL")) \
or process_boolean_str(config_ini["email"]["MAIL_SSL"])
MAILGUN_API_KEY: str = os.getenv("MAILGUN_API_KEY") \
or empty_str_cast(config_ini["email"]["MAILGUN_API_KEY"])
MAILGUN_BASE_URL: str = os.getenv("MAILGUN_BASE_URL") \
or empty_str_cast(config_ini["email"]["MAILGUN_API_KEY"])
"""
=== LOGS ===
@@ -165,9 +222,9 @@ class Config(object):
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 = os.getenv("LOG_FOLDER") or os.path.join(
os.path.dirname(os.path.abspath(__file__)), "logs"
)
LOG_FOLDER: str = os.getenv("LOG_FOLDER") \
or empty_str_cast(config_ini["logs"]["LOG_FOLDER"]) \
or os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
"""
=== UPLOADS ===
@@ -191,15 +248,26 @@ class Config(object):
A URL pointing to a custom S3 implementation.
"""
UPLOAD_PROVIDER = os.getenv("UPLOAD_PROVIDER") or "filesystem"
UPLOAD_FOLDER = os.getenv("UPLOAD_FOLDER") or os.path.join(
os.path.dirname(os.path.abspath(__file__)), "uploads"
)
UPLOAD_PROVIDER: str = os.getenv("UPLOAD_PROVIDER") \
or empty_str_cast(config_ini["uploads"]["UPLOAD_PROVIDER"]) \
or "filesystem"
UPLOAD_FOLDER: str = os.getenv("UPLOAD_FOLDER") \
or 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 = os.getenv("AWS_ACCESS_KEY_ID")
AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY")
AWS_S3_BUCKET = os.getenv("AWS_S3_BUCKET")
AWS_S3_ENDPOINT_URL = os.getenv("AWS_S3_ENDPOINT_URL")
AWS_ACCESS_KEY_ID: str = os.getenv("AWS_ACCESS_KEY_ID") \
or empty_str_cast(config_ini["uploads"]["AWS_ACCESS_KEY_ID"])
AWS_SECRET_ACCESS_KEY: str = os.getenv("AWS_SECRET_ACCESS_KEY") \
or empty_str_cast(config_ini["uploads"]["AWS_SECRET_ACCESS_KEY"])
AWS_S3_BUCKET: str = os.getenv("AWS_S3_BUCKET") \
or empty_str_cast(config_ini["uploads"]["AWS_S3_BUCKET"])
AWS_S3_ENDPOINT_URL: str = os.getenv("AWS_S3_ENDPOINT_URL") \
or empty_str_cast(config_ini["uploads"]["AWS_S3_ENDPOINT_URL"])
"""
=== OPTIONAL ===
@@ -214,16 +282,16 @@ class Config(object):
Alternatively if you specify `true` CTFd will default to the above behavior with all proxy settings set to 1.
TEMPLATES_AUTO_RELOAD:
Specifies whether Flask should check for modifications to templates and reload them automatically.
Specifies whether Flask should check for modifications to templates and reload them automatically. Defaults True.
SQLALCHEMY_TRACK_MODIFICATIONS:
Automatically disabled to suppress warnings and save memory. You should only enable this if you need it.
Automatically disabled to suppress warnings and save memory. You should only enable this if you need it. Defaults False.
SWAGGER_UI:
Enable the Swagger UI endpoint at /api/v1/
UPDATE_CHECK:
Specifies whether or not CTFd will check whether or not there is a new version of CTFd
Specifies whether or not CTFd will check whether or not there is a new version of CTFd. Defaults True.
APPLICATION_ROOT:
Specifies what path CTFd is mounted under. It can be used to run CTFd in a subdirectory.
@@ -237,18 +305,44 @@ class Config(object):
https://docs.sqlalchemy.org/en/13/core/engines.html#sqlalchemy.create_engine
https://flask-sqlalchemy.palletsprojects.com/en/2.x/config/#configuration-keys
"""
REVERSE_PROXY = os.getenv("REVERSE_PROXY") or False
TEMPLATES_AUTO_RELOAD = not os.getenv("TEMPLATES_AUTO_RELOAD") # Defaults True
SQLALCHEMY_TRACK_MODIFICATIONS = (
os.getenv("SQLALCHEMY_TRACK_MODIFICATIONS") is not None
) # Defaults False
SWAGGER_UI = "/" if os.getenv("SWAGGER_UI") is not None else False # Defaults False
UPDATE_CHECK = not os.getenv("UPDATE_CHECK") # Defaults True
APPLICATION_ROOT = os.getenv("APPLICATION_ROOT") or "/"
SERVER_SENT_EVENTS = not os.getenv("SERVER_SENT_EVENTS") # Defaults True
REVERSE_PROXY: bool = process_boolean_str(os.getenv("REVERSE_PROXY")) \
or empty_str_cast(config_ini["optional"]["REVERSE_PROXY"]) \
or False
TEMPLATES_AUTO_RELOAD: bool = process_boolean_str(os.getenv("TEMPLATES_AUTO_RELOAD")) \
or empty_str_cast(config_ini["optional"]["TEMPLATES_AUTO_RELOAD"]) \
or True
SQLALCHEMY_TRACK_MODIFICATIONS: bool = process_boolean_str(os.getenv("SQLALCHEMY_TRACK_MODIFICATIONS")) \
or empty_str_cast(config_ini["optional"]["SQLALCHEMY_TRACK_MODIFICATIONS"]) \
or False
SWAGGER_UI: bool = os.getenv("SWAGGER_UI") \
or empty_str_cast(config_ini["optional"]["SWAGGER_UI"]) \
or False
SWAGGER_UI_ENDPOINT: str = "/" if SWAGGER_UI else None
UPDATE_CHECK: bool = process_boolean_str(os.getenv("UPDATE_CHECK")) \
or empty_str_cast(config_ini["optional"]["UPDATE_CHECK"]) \
or True
APPLICATION_ROOT: str = os.getenv("APPLICATION_ROOT") \
or empty_str_cast(config_ini["optional"]["APPLICATION_ROOT"]) \
or "/"
SERVER_SENT_EVENTS: bool = process_boolean_str(os.getenv("SERVER_SENT_EVENTS")) \
or empty_str_cast(config_ini["optional"]["SERVER_SENT_EVENTS"]) \
or True
if DATABASE_URL.startswith("sqlite") is False:
SQLALCHEMY_ENGINE_OPTIONS = {
"max_overflow": int(os.getenv("SQLALCHEMY_MAX_OVERFLOW", 20))
"max_overflow": int(os.getenv("SQLALCHEMY_MAX_OVERFLOW", 0))
or int(empty_str_cast(config_ini["optional"]["SQLALCHEMY_MAX_OVERFLOW"], default=0)) # noqa: E131
or 20, # noqa: E131
"pool_pre_ping": process_boolean_str(os.getenv("SQLALCHEMY_POOL_PRE_PING"))
or empty_str_cast(config_ini["optional"]["SQLALCHEMY_POOL_PRE_PING"]) # noqa: E131
or True, # noqa: E131
}
"""
@@ -257,8 +351,11 @@ class Config(object):
MajorLeagueCyber Integration
Register an event at https://majorleaguecyber.org/ and use the Client ID and Client Secret here
"""
OAUTH_CLIENT_ID = os.getenv("OAUTH_CLIENT_ID")
OAUTH_CLIENT_SECRET = os.getenv("OAUTH_CLIENT_SECRET")
OAUTH_CLIENT_ID: str = os.getenv("OAUTH_CLIENT_ID") \
or empty_str_cast(config_ini["oauth"]["OAUTH_CLIENT_ID"])
OAUTH_CLIENT_SECRET: str = os.getenv("OAUTH_CLIENT_SECRET") \
or empty_str_cast(config_ini["oauth"]["OAUTH_CLIENT_SECRET"])
# fmt: on
class TestingConfig(Config):

View File

@@ -1,4 +1,5 @@
from enum import Enum
from flask import current_app
JS_ENUMS = {}

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

@@ -0,0 +1,67 @@
import json
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 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("theme_header", default="CTFd")
@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):
return json.loads(get_config("theme_settings", default="null"))
Configs = _ConfigsWrapper()

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

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

View File

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

View File

@@ -18,3 +18,26 @@ TeamAttrs = namedtuple(
"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

@@ -20,3 +20,26 @@ UserAttrs = namedtuple(
"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()

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

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

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

@@ -0,0 +1,33 @@
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
class RegistrationForm(BaseForm):
name = StringField("User Name", validators=[InputRequired()])
email = EmailField("Email", validators=[InputRequired()])
password = PasswordField("Password", validators=[InputRequired()])
submit = SubmitField("Submit")
class LoginForm(BaseForm):
name = StringField("User Name or Email", validators=[InputRequired()])
password = PasswordField("Password", validators=[InputRequired()])
submit = SubmitField("Submit")
class ConfirmForm(BaseForm):
submit = SubmitField("Resend")
class ResetPasswordRequestForm(BaseForm):
email = EmailField("Email", validators=[InputRequired()])
submit = SubmitField("Submit")
class ResetPasswordForm(BaseForm):
password = PasswordField("Password", validators=[InputRequired()])
submit = SubmitField("Submit")

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

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

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

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

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

@@ -0,0 +1,62 @@
from wtforms import BooleanField, SelectField, StringField
from wtforms.fields.html5 import IntegerField
from wtforms.widgets.html5 import NumberInput
from CTFd.forms import BaseForm
from CTFd.forms.fields import SubmitField
from CTFd.models import db
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_size = IntegerField(
widget=NumberInput(min=0), description="Amount of users per team"
)
verify_emails = SelectField(
"Verify Emails",
description="Control whether users must confirm their email addresses before playing",
choices=[("true", "Enabled"), ("false", "Disabled")],
default="false",
)
name_changes = SelectField(
"Name Changes",
description="Control whether users can change their names",
choices=[("true", "Enabled"), ("false", "Disabled")],
default="true",
)
submit = SubmitField("Update")
class ExportCSVForm(BaseForm):
table = SelectField(
"Database Table",
choices=list(
zip(sorted(db.metadata.tables.keys()), sorted(db.metadata.tables.keys()))
),
)
submit = SubmitField("Download CSV")

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

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

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

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

View File

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

33
CTFd/forms/pages.py Normal file
View File

@@ -0,0 +1,33 @@
from wtforms import (
BooleanField,
HiddenField,
MultipleFileField,
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")
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()])

22
CTFd/forms/self.py Normal file
View File

@@ -0,0 +1,22 @@
from wtforms import PasswordField, SelectField, StringField
from wtforms.fields.html5 import DateField, URLField
from CTFd.forms import BaseForm
from CTFd.forms.fields import SubmitField
from CTFd.utils.countries import SELECT_COUNTRIES_LIST
class SettingsForm(BaseForm):
name = StringField("User Name")
email = StringField("Email")
password = PasswordField("Password")
confirm = PasswordField("Current Password")
affiliation = StringField("Affiliation")
website = URLField("Website")
country = SelectField("Country", choices=SELECT_COUNTRIES_LIST)
submit = SubmitField("Submit")
class TokensForm(BaseForm):
expiration = DateField("Expiration")
submit = SubmitField("Generate")

66
CTFd/forms/setup.py Normal file
View File

@@ -0,0 +1,66 @@
from wtforms import (
HiddenField,
PasswordField,
RadioField,
SelectField,
StringField,
TextAreaField,
)
from wtforms.fields.html5 import EmailField
from wtforms.validators import InputRequired
from CTFd.forms import BaseForm
from CTFd.forms.fields import SubmitField
from CTFd.utils.config import get_themes
class SetupForm(BaseForm):
ctf_name = StringField(
"Event Name", description="The name of your CTF event/workshop"
)
ctf_description = TextAreaField(
"Event Description", description="Description for the CTF"
)
user_mode = RadioField(
"User Mode",
choices=[("teams", "Team Mode"), ("users", "User Mode")],
default="teams",
description="Controls whether users join together in teams to play (Team Mode) or play as themselves (User Mode)",
validators=[InputRequired()],
)
name = StringField(
"Admin Username",
description="Your username for the administration account",
validators=[InputRequired()],
)
email = EmailField(
"Admin Email",
description="Your email address for the administration account",
validators=[InputRequired()],
)
password = PasswordField(
"Admin Password",
description="Your password for the administration account",
validators=[InputRequired()],
)
ctf_theme = SelectField(
"Theme",
description="CTFd Theme to use",
choices=list(zip(get_themes(), get_themes())),
default="core",
validators=[InputRequired()],
)
theme_color = HiddenField(
"Theme Color",
description="Color used by theme to control aesthetics. Requires theme support. Optional.",
)
start = StringField(
"Start Time", description="Time when your CTF is scheduled to start. Optional."
)
end = StringField(
"End Time", description="Time when your CTF is scheduled to end. Optional."
)
submit = SubmitField("Finish")

16
CTFd/forms/submissions.py Normal file
View File

@@ -0,0 +1,16 @@
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")],
default="provided",
validators=[InputRequired()],
)
q = StringField("Parameter", validators=[InputRequired()])
submit = SubmitField("Search")

82
CTFd/forms/teams.py Normal file
View File

@@ -0,0 +1,82 @@
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.utils.countries import SELECT_COUNTRIES_LIST
class TeamJoinForm(BaseForm):
name = StringField("Team Name", validators=[InputRequired()])
password = PasswordField("Team Password", validators=[InputRequired()])
submit = SubmitField("Join")
class TeamRegisterForm(BaseForm):
name = StringField("Team Name", validators=[InputRequired()])
password = PasswordField("Team Password", validators=[InputRequired()])
submit = SubmitField("Create")
class TeamSettingsForm(BaseForm):
name = StringField("Team Name")
confirm = PasswordField("Current Password")
password = PasswordField("Team Password")
affiliation = StringField("Affiliation")
website = URLField("Website")
country = SelectField("Country", choices=SELECT_COUNTRIES_LIST)
submit = SubmitField("Submit")
class TeamCaptainForm(BaseForm):
# Choices are populated dynamically at form creation time
captain_id = SelectField("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(
"Search Field",
choices=[
("name", "Name"),
("affiliation", "Affiliation"),
("website", "Website"),
],
default="name",
validators=[InputRequired()],
)
q = StringField("Parameter", validators=[InputRequired()])
submit = SubmitField("Search")
class TeamCreateForm(BaseForm):
name = StringField("Team Name", validators=[InputRequired()])
email = EmailField("Email")
password = PasswordField("Password")
website = URLField("Website")
affiliation = StringField("Affiliation")
country = SelectField("Country", choices=SELECT_COUNTRIES_LIST)
hidden = BooleanField("Hidden")
banned = BooleanField("Banned")
submit = SubmitField("Submit")
class TeamEditForm(TeamCreateForm):
pass

58
CTFd/forms/users.py Normal file
View File

@@ -0,0 +1,58 @@
from wtforms import BooleanField, PasswordField, SelectField, 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.utils.countries import SELECT_COUNTRIES_LIST
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(
"Search Field",
choices=[
("name", "Name"),
("affiliation", "Affiliation"),
("website", "Website"),
],
default="name",
validators=[InputRequired()],
)
q = StringField("Parameter", validators=[InputRequired()])
submit = SubmitField("Search")
class UserEditForm(BaseForm):
name = StringField("User Name", validators=[InputRequired()])
email = EmailField("Email", validators=[InputRequired()])
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")
class UserCreateForm(UserEditForm):
notify = BooleanField("Email account credentials to user", default=True)

View File

@@ -6,8 +6,6 @@ from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import column_property, validates
from CTFd.cache import cache
from CTFd.utils.crypto import hash_password
from CTFd.utils.humanize.numbers import ordinalize
db = SQLAlchemy()
ma = Marshmallow()
@@ -81,6 +79,13 @@ class Challenges(db.Model):
__mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type}
@property
def html(self):
from CTFd.utils.config.pages import build_html
from CTFd.utils.helpers import markup
return markup(build_html(self.description))
def __init__(self, *args, **kwargs):
super(Challenges, self).__init__(**kwargs)
@@ -256,6 +261,8 @@ class Users(db.Model):
@validates("password")
def validate_password(self, key, plaintext):
from CTFd.utils.crypto import hash_password
return hash_password(str(plaintext))
@hybrid_property
@@ -268,6 +275,16 @@ class Users(db.Model):
elif user_mode == "users":
return self.id
@hybrid_property
def account(self):
from CTFd.utils import get_config
user_mode = get_config("user_mode")
if user_mode == "teams":
return self.team
elif user_mode == "users":
return self
@property
def solves(self):
return self.get_solves(admin=False)
@@ -365,6 +382,7 @@ class Users(db.Model):
application itself will result in a circular import.
"""
from CTFd.utils.scores import get_user_standings
from CTFd.utils.humanize.numbers import ordinalize
standings = get_user_standings(admin=admin)
@@ -418,6 +436,8 @@ class Teams(db.Model):
@validates("password")
def validate_password(self, key, plaintext):
from CTFd.utils.crypto import hash_password
return hash_password(str(plaintext))
@property
@@ -509,6 +529,7 @@ class Teams(db.Model):
application itself will result in a circular import.
"""
from CTFd.utils.scores import get_team_standings
from CTFd.utils.humanize.numbers import ordinalize
standings = get_team_standings(admin=admin)

View File

@@ -4,7 +4,7 @@ import os
from collections import namedtuple
from flask import current_app as app
from flask import send_file, send_from_directory
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
@@ -114,6 +114,9 @@ def register_admin_plugin_menu_bar(title, route):
:param route: A string that is the href used by the link
:return:
"""
if (route.startswith("http://") or route.startswith("https://")) is False:
route = url_for("views.static_html", route=route)
am = Menu(title=title, route=route)
app.admin_plugin_menu_bar.append(am)
@@ -135,6 +138,9 @@ def register_user_page_menu_bar(title, route):
:param route: A string that is the href used by the link
:return:
"""
if (route.startswith("http://") or route.startswith("https://")) is False:
route = url_for("views.static_html", route=route)
p = Menu(title=title, route=route)
app.plugin_menu_bar.append(p)

View File

@@ -11,7 +11,7 @@ from CTFd.models import (
db,
)
from CTFd.plugins import register_plugin_assets_directory
from CTFd.plugins.flags import get_flag_class
from CTFd.plugins.flags import FlagException, get_flag_class
from CTFd.utils.uploads import delete_file
from CTFd.utils.user import get_ip
@@ -21,6 +21,153 @@ class BaseChallenge(object):
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,
"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()
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, e.message
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):
@@ -42,151 +189,7 @@ class CTFdStandardChallenge(BaseChallenge):
blueprint = Blueprint(
"standard", __name__, template_folder="templates", static_folder="assets"
)
@staticmethod
def create(request):
"""
This method is used to process the challenge creation request.
:param request:
:return:
"""
data = request.form or request.get_json()
challenge = Challenges(**data)
db.session.add(challenge)
db.session.commit()
return challenge
@staticmethod
def read(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,
"category": challenge.category,
"state": challenge.state,
"max_attempts": challenge.max_attempts,
"type": challenge.type,
"type_data": {
"id": CTFdStandardChallenge.id,
"name": CTFdStandardChallenge.name,
"templates": CTFdStandardChallenge.templates,
"scripts": CTFdStandardChallenge.scripts,
},
}
return data
@staticmethod
def update(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
@staticmethod
def delete(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()
db.session.commit()
@staticmethod
def attempt(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:
if get_flag_class(flag.type).compare(flag, submission):
return True, "Correct"
return False, "Incorrect"
@staticmethod
def solve(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()
db.session.close()
@staticmethod
def fail(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()
db.session.close()
challenge_model = Challenges
def get_chal_class(class_id):

View File

@@ -1,64 +1 @@
<form method="POST" action="{{ script_root }}/admin/challenges/new" enctype="multipart/form-data">
<div class="form-group">
<label>
Name:<br>
<small class="form-text text-muted">
The name of your challenge
</small>
</label>
<input type="text" class="form-control" name="name" placeholder="Enter challenge name">
</div>
<div class="form-group">
<label>
Category:<br>
<small class="form-text text-muted">
The category of your challenge
</small>
</label>
<input type="text" class="form-control" name="category" placeholder="Enter challenge category">
</div>
<ul class="nav nav-tabs" role="tablist" id="new-desc-edit">
<li class="nav-item">
<a class="nav-link active" href="#new-desc-write" aria-controls="home" role="tab"
data-toggle="tab" tabindex="-1">Write</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#new-desc-preview" aria-controls="home" role="tab" data-toggle="tab" tabindex="-1">Preview</a>
</li>
</ul>
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="new-desc-write">
<div class="form-group">
<label>
Message:<br>
<small class="form-text text-muted">
Use this to give a brief introduction to your challenge.
</small>
</label>
<textarea id="new-desc-editor" class="form-control" name="description" rows="10"></textarea>
</div>
</div>
<div role="tabpanel" class="tab-pane content" id="new-desc-preview" style="height:234px; overflow-y: scroll;">
</div>
</div>
<div class="form-group">
<label>
Value:<br>
<small class="form-text text-muted">
This is how many points are rewarded for solving this challenge.
</small>
</label>
<input type="number" class="form-control" name="value" placeholder="Enter value" required>
</div>
<input type="hidden" name="state" value="hidden">
<input type="hidden" name="type" value="standard">
<div class="form-group">
<button class="btn btn-primary float-right create-challenge-submit" type="submit">Create</button>
</div>
</form>
{% extends "admin/challenges/create.html" %}

View File

@@ -1,39 +1,4 @@
CTFd.plugin.run((_CTFd) => {
const $ = _CTFd.lib.$
const md = _CTFd.lib.markdown()
$('a[href="#new-desc-preview"]').on('shown.bs.tab', function (event) {
if (event.target.hash == '#new-desc-preview') {
var editor_value = $('#new-desc-editor').val();
$(event.target.hash).html(
md.render(editor_value)
);
}
});
// $('#desc-edit').on('shown.bs.tab', function (event) {
// if (event.target.hash == '#desc-preview') {
// var editor_value = $('#desc-editor').val();
// $(event.target.hash).html(
// window.challenge.render(editor_value)
// );
// }
// });
// $('#new-desc-edit').on('shown.bs.tab', function (event) {
// if (event.target.hash == '#new-desc-preview') {
// var editor_value = $('#new-desc-editor').val();
// $(event.target.hash).html(
// window.challenge.render(editor_value)
// );
// }
// });
// $("#solve-attempts-checkbox").change(function () {
// if (this.checked) {
// $('#solve-attempts-input').show();
// } else {
// $('#solve-attempts-input').hide();
// $('#max_attempts').val('');
// }
// });
// $(document).ready(function () {
// $('[data-toggle="tooltip"]').tooltip();
// });
})

View File

@@ -1,64 +1 @@
<form method="POST">
<div class="form-group">
<label>
Name<br>
<small class="form-text text-muted">Challenge Name</small>
</label>
<input type="text" class="form-control chal-name" name="name" value="{{ challenge.name }}">
</div>
<div class="form-group">
<label>
Category<br>
<small class="form-text text-muted">Challenge Category</small>
</label>
<input type="text" class="form-control chal-category" name="category" value="{{ challenge.category }}">
</div>
<div class="form-group">
<label>
Message<br>
<small class="form-text text-muted">
Use this to give a brief introduction to your challenge.
</small>
</label>
<textarea id="desc-editor" class="form-control chal-desc-editor" name="description" rows="10">{{ challenge.description }}</textarea>
</div>
<div class="form-group">
<label for="value">
Value<br>
<small class="form-text text-muted">
This is how many points teams will receive once they solve this challenge.
</small>
</label>
<input type="number" class="form-control chal-value" name="value" value="{{ challenge.value }}" required>
</div>
<div class="form-group">
<label>
Max Attempts<br>
<small class="form-text text-muted">Maximum amount of attempts users receive. Leave at 0 for unlimited.</small>
</label>
<input type="number" class="form-control chal-attempts" name="max_attempts" value="{{ challenge.max_attempts }}">
</div>
<div class="form-group">
<label>
State<br>
<small class="form-text text-muted">Changes the state of the challenge (e.g. visible, hidden)</small>
</label>
<select class="form-control custom-select" name="state">
<option value="visible" {% if challenge.state == "visible" %}selected{% endif %}>Visible</option>
<option value="hidden" {% if challenge.state == "hidden" %}selected{% endif %}>Hidden</option>
</select>
</div>
<div>
<button class="btn btn-success btn-outlined float-right" type="submit">
Update
</button>
</div>
</form>
{% extends "admin/challenges/update.html" %}

View File

@@ -1,117 +1,16 @@
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-body">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link active" href="#challenge">Challenge</a>
</li>
{% if solves == None %}
{% else %}
<li class="nav-item">
<a class="nav-link challenge-solves" href="#solves">
{{ solves }} {% if solves > 1 %}Solves{% else %}Solves{% endif %}
</a>
</li>
{% endif %}
</ul>
<div role="tabpanel">
<div class="tab-content">
<div role="tabpanel" class="tab-pane fade show active" id="challenge">
<h2 class='challenge-name text-center pt-3'>{{ name }}</h2>
<h3 class="challenge-value text-center">{{ value }}</h3>
<div class="challenge-tags text-center">
{% for tag in tags %}
<span class='badge badge-info challenge-tag'>{{ tag }}</span>
{% endfor %}
</div>
<span class="challenge-desc">{{ description | safe }}</span>
<div class="challenge-hints hint-row row">
{% for hint in hints %}
<div class='col-md-12 hint-button-wrapper text-center mb-3'>
<a class="btn btn-info btn-hint btn-block load-hint" href="javascript:;" data-hint-id="{{ hint.id }}">
{% if hint.content %}
<small>
View Hint
</small>
{% else %}
{% if hint.cost %}
<small>
Unlock Hint for {{ hint.cost }} points
</small>
{% else %}
<small>
View Hint
</small>
{% endif %}
{% endif %}
</a>
</div>
{% endfor %}
</div>
<div class="row challenge-files text-center pb-3">
{% for file in files %}
<div class='col-md-4 col-sm-4 col-xs-12 file-button-wrapper d-block'>
<a class='btn btn-info btn-file mb-1 d-inline-block px-2 w-100 text-truncate'
href='{{ file }}'>
<i class="fas fa-download"></i>
<small>
{% set segments = file.split('/') %}
{% set file = segments | last %}
{% set token = file.split('?') | last %}
{% if token %}
{{ file | replace("?" + token, "") }}
{% else %}
{{ file }}
{% endif %}
</small>
</a>
</div>
{% endfor %}
</div>
{% extends "challenge.html" %}
<div class="row submit-row">
<div class="col-md-9 form-group">
<input class="form-control" type="text" name="answer" id="submission-input" placeholder="Flag"/>
<input id="challenge-id" type="hidden" value="{{ id }}">
</div>
<div class="col-md-3 form-group key-submit">
<button type="submit" id="submit-key" tabindex="0"
class="btn btn-md btn-outline-secondary float-right">Submit
</button>
</div>
</div>
<div class="row notification-row">
<div class="col-md-12">
<div id="result-notification" class="alert alert-dismissable text-center w-100"
role="alert" style="display: none;">
<strong id="result-message"></strong>
</div>
</div>
</div>
</div>
<div role="tabpanel" class="tab-pane fade" id="solves">
<div class="row">
<div class="col-md-12">
<table class="table table-striped text-center">
<thead>
<tr>
<td><b>Name</b>
</td>
<td><b>Date</b>
</td>
</tr>
</thead>
<tbody id="challenge-solves-names">
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% block description %}
{{ challenge.html }}
{% endblock %}
{% block input %}
<input id="challenge-id" class="challenge-id" type="hidden" value="{{ challenge.id }}">
<input id="challenge-input" class="challenge-input" type="text" name="answer" placeholder="Flag"/>
{% endblock %}
{% block submit %}
<button id="challenge-submit" class="challenge-submit" type="submit">
Submit
</button>
{% endblock %}

View File

@@ -15,7 +15,7 @@ CTFd._internal.challenge.postRender = function () { }
CTFd._internal.challenge.submit = function (preview) {
var challenge_id = parseInt(CTFd.lib.$('#challenge-id').val())
var submission = CTFd.lib.$('#submission-input').val()
var submission = CTFd.lib.$('#challenge-input').val()
var body = {
'challenge_id': challenge_id,

View File

@@ -16,9 +16,9 @@ Within CTFd you are free to mix and match regular and dynamic challenges.
The current implementation requires the challenge to keep track of three values:
* Initial - The original point valuation
* Decay - The amount of solves before the challenge will be at the minimum
* Minimum - The lowest possible point valuation
- Initial - The original point valuation
- Decay - The amount of solves before the challenge will be at the minimum
- Minimum - The lowest possible point valuation
The value decay logic is implemented with the following math:
@@ -50,5 +50,5 @@ so that higher valued challenges have a slower drop from their initial value.
**REQUIRES: CTFd >= v1.2.0**
1. Clone this repository to `CTFd/plugins`. It is important that the folder is
named `DynamicValueChallenge` so CTFd can serve the files in the `assets`
directory.
named `DynamicValueChallenge` so CTFd can serve the files in the `assets`
directory.

View File

@@ -4,23 +4,25 @@ import math
from flask import Blueprint
from CTFd.models import (
ChallengeFiles,
Challenges,
Fails,
Flags,
Hints,
Solves,
Tags,
db,
)
from CTFd.models import Challenges, Solves, db
from CTFd.plugins import register_plugin_assets_directory
from CTFd.plugins.migrations import upgrade
from CTFd.plugins.challenges import CHALLENGE_CLASSES, BaseChallenge
from CTFd.plugins.flags import get_flag_class
from CTFd.plugins.migrations import upgrade
from CTFd.utils.modes import get_model
from CTFd.utils.uploads import delete_file
from CTFd.utils.user import get_ip
class DynamicChallenge(Challenges):
__mapper_args__ = {"polymorphic_identity": "dynamic"}
id = db.Column(
db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE"), primary_key=True
)
initial = db.Column(db.Integer, default=0)
minimum = db.Column(db.Integer, default=0)
decay = db.Column(db.Integer, default=0)
def __init__(self, *args, **kwargs):
super(DynamicChallenge, self).__init__(**kwargs)
self.initial = kwargs["value"]
class DynamicValueChallenge(BaseChallenge):
@@ -45,6 +47,7 @@ class DynamicValueChallenge(BaseChallenge):
template_folder="templates",
static_folder="assets",
)
challenge_model = DynamicChallenge
@classmethod
def calculate_value(cls, challenge):
@@ -82,24 +85,8 @@ class DynamicValueChallenge(BaseChallenge):
db.session.commit()
return challenge
@staticmethod
def create(request):
"""
This method is used to process the challenge creation request.
:param request:
:return:
"""
data = request.form or request.get_json()
challenge = DynamicChallenge(**data)
db.session.add(challenge)
db.session.commit()
return challenge
@staticmethod
def read(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.
@@ -120,16 +107,16 @@ class DynamicValueChallenge(BaseChallenge):
"max_attempts": challenge.max_attempts,
"type": challenge.type,
"type_data": {
"id": DynamicValueChallenge.id,
"name": DynamicValueChallenge.name,
"templates": DynamicValueChallenge.templates,
"scripts": DynamicValueChallenge.scripts,
"id": cls.id,
"name": cls.name,
"templates": cls.templates,
"scripts": cls.scripts,
},
}
return data
@staticmethod
def update(challenge, request):
@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.
@@ -148,109 +135,12 @@ class DynamicValueChallenge(BaseChallenge):
return DynamicValueChallenge.calculate_value(challenge)
@staticmethod
def delete(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()
DynamicChallenge.query.filter_by(id=challenge.id).delete()
Challenges.query.filter_by(id=challenge.id).delete()
db.session.commit()
@staticmethod
def attempt(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:
if get_flag_class(flag.type).compare(flag, submission):
return True, "Correct"
return False, "Incorrect"
@staticmethod
def solve(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:
"""
challenge = DynamicChallenge.query.filter_by(id=challenge.id).first()
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 solve(cls, user, team, challenge, request):
super().solve(user, team, challenge, request)
DynamicValueChallenge.calculate_value(challenge)
@staticmethod
def fail(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 challenge: 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()
db.session.close()
class DynamicChallenge(Challenges):
__mapper_args__ = {"polymorphic_identity": "dynamic"}
id = db.Column(
db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE"), primary_key=True
)
initial = db.Column(db.Integer, default=0)
minimum = db.Column(db.Integer, default=0)
decay = db.Column(db.Integer, default=0)
def __init__(self, *args, **kwargs):
super(DynamicChallenge, self).__init__(**kwargs)
self.initial = kwargs["value"]
def load(app):
upgrade()

View File

@@ -1,88 +1,43 @@
<form method="POST" action="{{ script_root }}/admin/chal/new" enctype="multipart/form-data">
<div class="form-group">
<div class="alert alert-secondary" role="alert">
Dynamic value challenges decrease in value as they receive solves. The more solves a dynamic challenge has,
the
lower its value is to everyone who has solved it.
</div>
</div>
{% extends "admin/challenges/create.html" %}
<div class="form-group">
<label for="name">Name<br>
<small class="form-text text-muted">
The name of your challenge
</small>
</label>
<input type="text" class="form-control" name="name" placeholder="Enter challenge name">
</div>
<div class="form-group">
<label for="category">Category<br>
<small class="form-text text-muted">
The category of your challenge
</small>
</label>
<input type="text" class="form-control" name="category" placeholder="Enter challenge category">
</div>
{% block header %}
<div class="alert alert-secondary" role="alert">
Dynamic value challenges decrease in value as they receive solves. The more solves a dynamic challenge has,
the lower its value is to everyone who has solved it.
</div>
{% endblock %}
<ul class="nav nav-tabs" role="tablist" id="new-desc-edit">
<li class="nav-item">
<a class="nav-link active" href="#new-desc-write" aria-controls="home" role="tab"
data-toggle="tab">Write</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#new-desc-preview" aria-controls="home" role="tab" data-toggle="tab">Preview</a>
</li>
</ul>
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="new-desc-write">
<div class="form-group">
<label for="message-text" class="control-label">Message
<small class="form-text text-muted">
Use this to give a brief introduction to your challenge. The description supports HTML and
Markdown.
</small>
</label>
<textarea id="new-desc-editor" class="form-control" name="description" rows="10"></textarea>
</div>
</div>
<div role="tabpanel" class="tab-pane content" id="new-desc-preview" style="height:234px; overflow-y: scroll;">
</div>
</div>
{% block value %}
<div class="form-group">
<label for="value">Initial Value<br>
<small class="form-text text-muted">
This is how many points the challenge is worth initially.
</small>
</label>
<input type="number" class="form-control" name="value" placeholder="Enter value" required>
<div class="form-group">
<label for="value">Initial Value<br>
<small class="form-text text-muted">
This is how many points the challenge is worth initially.
</small>
</label>
<input type="number" class="form-control" name="value" placeholder="Enter value" required>
</div>
</div>
<div class="form-group">
<label for="value">Decay Limit<br>
<small class="form-text text-muted">
The amount of solves before the challenge reaches its minimum value
</small>
</label>
<input type="number" class="form-control" name="decay" placeholder="Enter decay limit" required>
</div>
<div class="form-group">
<label for="value">Decay Limit<br>
<small class="form-text text-muted">
The amount of solves before the challenge reaches its minimum value
</small>
</label>
<input type="number" class="form-control" name="decay" placeholder="Enter decay limit" required>
</div>
<div class="form-group">
<label for="value">Minimum Value<br>
<small class="form-text text-muted">
This is the lowest that the challenge can be worth
</small>
</label>
<input type="number" class="form-control" name="minimum" placeholder="Enter minimum value" required>
</div>
{% endblock %}
<div class="form-group">
<label for="value">Minimum Value<br>
<small class="form-text text-muted">
This is the lowest that the challenge can be worth
</small>
</label>
<input type="number" class="form-control" name="minimum" placeholder="Enter minimum value" required>
</div>
<input type="hidden" name="state" value="hidden">
<input type="hidden" value="dynamic" name="type" id="chaltype">
<div class="form-group">
<button class="btn btn-primary float-right create-challenge-submit" type="submit">Create</button>
</div>
</form>
{% block type %}
<input type="hidden" value="dynamic" name="type" id="chaltype">
{% endblock %}

View File

@@ -1,29 +1,12 @@
// Markdown Preview
$('#desc-edit').on('shown.bs.tab', function (event) {
if (event.target.hash == '#desc-preview'){
var editor_value = $('#desc-editor').val();
$(event.target.hash).html(
window.challenge.render(editor_value)
);
}
});
$('#new-desc-edit').on('shown.bs.tab', function (event) {
if (event.target.hash == '#new-desc-preview'){
var editor_value = $('#new-desc-editor').val();
$(event.target.hash).html(
window.challenge.render(editor_value)
);
}
});
$("#solve-attempts-checkbox").change(function() {
if(this.checked) {
$('#solve-attempts-input').show();
} else {
$('#solve-attempts-input').hide();
$('#max_attempts').val('');
}
});
$(document).ready(function(){
$('[data-toggle="tooltip"]').tooltip();
});
CTFd.plugin.run((_CTFd) => {
const $ = _CTFd.lib.$
const md = _CTFd.lib.markdown()
$('a[href="#new-desc-preview"]').on('shown.bs.tab', function (event) {
if (event.target.hash == '#new-desc-preview') {
var editor_value = $('#new-desc-editor').val();
$(event.target.hash).html(
md.render(editor_value)
);
}
});
})

View File

@@ -1,91 +1,39 @@
<form method="POST">
<div class="form-group">
<label for="name">Name<br>
<small class="form-text text-muted">
The name of your challenge
</small>
</label>
<input type="text" class="form-control chal-name" name="name" value="{{ challenge.name }}">
</div>
<div class="form-group">
<label for="category">Category<br>
<small class="form-text text-muted">
The category of your challenge
</small>
</label>
<input type="text" class="form-control chal-category" name="category" value="{{ challenge.category }}">
</div>
{% extends "admin/challenges/update.html" %}
<div class="form-group">
<label for="message-text" class="control-label">Message<br>
<small class="form-text text-muted">
Use this to give a brief introduction to your challenge.
</small>
</label>
<textarea id="desc-editor" class="form-control chal-desc-editor" name="description" rows="10">{{ challenge.description }}</textarea>
</div>
{% block value %}
<div class="form-group">
<label for="value">Current Value<br>
<small class="form-text text-muted">
This is how many points the challenge is worth right now.
</small>
</label>
<input type="number" class="form-control chal-value" name="value" value="{{ challenge.value }}" disabled>
</div>
<div class="form-group">
<label for="value">Current Value<br>
<small class="form-text text-muted">
This is how many points the challenge is worth right now.
</small>
</label>
<input type="number" class="form-control chal-value" name="value" value="{{ challenge.value }}" disabled>
</div>
<div class="form-group">
<label for="value">Initial Value<br>
<small class="form-text text-muted">
This is how many points the challenge was worth initially.
</small>
</label>
<input type="number" class="form-control chal-initial" name="initial" value="{{ challenge.initial }}" required>
</div>
<div class="form-group">
<label for="value">Initial Value<br>
<small class="form-text text-muted">
This is how many points the challenge was worth initially.
</small>
</label>
<input type="number" class="form-control chal-initial" name="initial" value="{{ challenge.initial }}" required>
</div>
<div class="form-group">
<label for="value">Decay Limit<br>
<small class="form-text text-muted">
The amount of solves before the challenge reaches its minimum value
</small>
</label>
<input type="number" class="form-control chal-decay" name="decay" value="{{ challenge.decay }}" required>
</div>
<div class="form-group">
<label for="value">Decay Limit<br>
<small class="form-text text-muted">
The amount of solves before the challenge reaches its minimum value
</small>
</label>
<input type="number" class="form-control chal-decay" name="decay" value="{{ challenge.decay }}" required>
</div>
<div class="form-group">
<label for="value">Minimum Value<br>
<small class="form-text text-muted">
This is the lowest that the challenge can be worth
</small>
</label>
<input type="number" class="form-control chal-minimum" name="minimum" value="{{ challenge.minimum }}" required>
</div>
<div class="form-group">
<label>
Max Attempts<br>
<small class="form-text text-muted">Maximum amount of attempts users receive. Leave at 0 for unlimited.</small>
</label>
<input type="number" class="form-control chal-attempts" name="max_attempts"
value="{{ challenge.max_attempts }}">
</div>
<div class="form-group">
<label>
State<br>
<small class="form-text text-muted">Changes the state of the challenge (e.g. visible, hidden)</small>
</label>
<select class="form-control custom-select" name="state">
<option value="visible" {% if challenge.state == "visible" %}selected{% endif %}>Visible</option>
<option value="hidden" {% if challenge.state == "hidden" %}selected{% endif %}>Hidden</option>
</select>
</div>
<div>
<button class="btn btn-success btn-outlined float-right" type="submit">
Update
</button>
</div>
</form>
<div class="form-group">
<label for="value">Minimum Value<br>
<small class="form-text text-muted">
This is the lowest that the challenge can be worth
</small>
</label>
<input type="number" class="form-control chal-minimum" name="minimum" value="{{ challenge.minimum }}" required>
</div>
{% endblock %}

View File

@@ -1,118 +1,16 @@
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-body">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link active" href="#challenge">Challenge</a>
</li>
{% if solves == None %}
{% else %}
<li class="nav-item">
<a class="nav-link challenge-solves" href="#solves">
{{ solves }} {% if solves > 1 %}Solves{% else %}Solves{% endif %}
</a>
</li>
{% endif %}
</ul>
<div role="tabpanel">
<div class="tab-content">
<div role="tabpanel" class="tab-pane fade show active" id="challenge">
<h2 class='challenge-name text-center pt-3'>{{ name }}</h2>
<h3 class="challenge-value text-center">{{ value }}</h3>
<div class="challenge-tags text-center">
{% for tag in tags %}
<span class='badge badge-info challenge-tag'>{{ tag }}</span>
{% endfor %}
</div>
<span class="challenge-desc">{{ description | safe }}</span>
<div class="challenge-hints hint-row row">
{% for hint in hints %}
<div class='col-md-12 hint-button-wrapper text-center mb-3'>
<a class="btn btn-info btn-hint btn-block load-hint" href="javascript:;" data-hint-id="{{ hint.id }}">
{% if hint.hint %}
<small>
View Hint
</small>
{% else %}
{% if hint.cost %}
<small>
Unlock Hint for {{ hint.cost }} points
</small>
{% else %}
<small>
View Hint
</small>
{% endif %}
{% endif %}
</a>
</div>
{% endfor %}
</div>
<div class="row challenge-files text-center pb-3">
{% for file in files %}
<div class='col-md-4 col-sm-4 col-xs-12 file-button-wrapper d-block'>
<a class='btn btn-info btn-file mb-1 d-inline-block px-2 w-100 text-truncate'
href='{{ file }}'>
<i class="fas fa-download"></i>
<small>
{% set segments = file.split('/') %}
{% set file = segments | last %}
{% set token = file.split('?') | last %}
{% if token %}
{{ file | replace("?" + token, "") }}
{% else %}
{{ file }}
{% endif %}
</small>
</a>
</div>
{% endfor %}
</div>
{% extends "challenge.html" %}
<div class="row submit-row">
<div class="col-md-9 form-group">
<input class="form-control" type="text" name="answer" id="submission-input"
placeholder="Flag"/>
<input id="challenge-id" type="hidden" value="{{ id }}">
</div>
<div class="col-md-3 form-group key-submit">
<button type="submit" id="submit-key" tabindex="0"
class="btn btn-md btn-outline-secondary float-right">Submit
</button>
</div>
</div>
<div class="row notification-row">
<div class="col-md-12">
<div id="result-notification" class="alert alert-dismissable text-center w-100"
role="alert" style="display: none;">
<strong id="result-message"></strong>
</div>
</div>
</div>
</div>
<div role="tabpanel" class="tab-pane fade" id="solves">
<div class="row">
<div class="col-md-12">
<table class="table table-striped text-center">
<thead>
<tr>
<td><b>Name</b>
</td>
<td><b>Date</b>
</td>
</tr>
</thead>
<tbody id="challenge-solves-names">
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% block description %}
{{ challenge.html }}
{% endblock %}
{% block input %}
<input id="challenge-id" class="challenge-id" type="hidden" value="{{ challenge.id }}">
<input id="challenge-input" class="challenge-input" type="text" name="answer" placeholder="Flag"/>
{% endblock %}
{% block submit %}
<button id="challenge-submit" class="challenge-submit" type="submit">
Submit
</button>
{% endblock %}

View File

@@ -3,6 +3,14 @@ import re
from CTFd.plugins import register_plugin_assets_directory
class FlagException(Exception):
def __init__(self, message):
self.message = message
def __str__(self):
return self.message
class BaseFlag(object):
name = None
templates = {}
@@ -55,8 +63,8 @@ class CTFdRegexFlag(BaseFlag):
else:
res = re.match(saved, provided)
# TODO: this needs plugin improvements. See #1425.
except re.error:
return False
except re.error as e:
raise FlagException("Regex parse error occured") from e
return res and res.group() == provided

View File

@@ -3,6 +3,7 @@ from flask import Blueprint, render_template
from CTFd.cache import cache, make_cache_key
from CTFd.utils import config
from CTFd.utils.decorators.visibility import check_score_visibility
from CTFd.utils.helpers import get_infos
from CTFd.utils.scores import get_standings
scoreboard = Blueprint("scoreboard", __name__)
@@ -12,9 +13,10 @@ scoreboard = Blueprint("scoreboard", __name__)
@check_score_visibility
@cache.cached(timeout=60, key_prefix=make_cache_key)
def listing():
infos = get_infos()
if config.is_scoreboard_frozen():
infos.append("Scoreboard has been frozen")
standings = get_standings()
return render_template(
"scoreboard.html",
standings=standings,
score_frozen=config.is_scoreboard_frozen(),
)
return render_template("scoreboard.html", standings=standings, infos=infos)

View File

@@ -1,6 +1,6 @@
from flask import Blueprint, redirect, render_template, request, url_for
from CTFd.cache import clear_user_session, clear_team_session
from CTFd.cache import clear_team_session, clear_user_session
from CTFd.models import Teams, db
from CTFd.utils import config, get_config
from CTFd.utils.crypto import verify_password
@@ -20,25 +20,34 @@ teams = Blueprint("teams", __name__)
@check_account_visibility
@require_team_mode
def listing():
page = abs(request.args.get("page", 1, type=int))
results_per_page = 50
page_start = results_per_page * (page - 1)
page_end = results_per_page * (page - 1) + results_per_page
q = request.args.get("q")
field = request.args.get("field", "name")
filters = []
if field not in ("name", "affiliation", "website"):
field = "name"
if q:
filters.append(getattr(Teams, field).like("%{}%".format(q)))
# TODO: Should teams confirm emails?
# if get_config('verify_emails'):
# count = Teams.query.filter_by(verified=True, banned=False).count()
# teams = Teams.query.filter_by(verified=True, banned=False).slice(page_start, page_end).all()
# else:
count = Teams.query.filter_by(hidden=False, banned=False).count()
teams = (
Teams.query.filter_by(hidden=False, banned=False)
.slice(page_start, page_end)
.all()
.filter(*filters)
.order_by(Teams.id.asc())
.paginate(per_page=50)
)
pages = int(count / results_per_page) + (count % results_per_page > 0)
return render_template("teams/teams.html", teams=teams, pages=pages, curr_page=page)
args = dict(request.args)
args.pop("page", 1)
return render_template(
"teams/teams.html",
teams=teams,
prev_page=url_for(request.endpoint, page=teams.prev_num, **args),
next_page=url_for(request.endpoint, page=teams.next_num, **args),
q=q,
field=field,
)
@teams.route("/teams/join", methods=["GET", "POST"])

View File

@@ -1,7 +1,7 @@
@import "includes/sticky-footer.css";
#score-graph {
height: 450px;
min-height: 400px;
display: block;
clear: both;
}
@@ -12,17 +12,22 @@
}
#keys-pie-graph {
height: 400px;
min-height: 400px;
display: block;
}
#categories-pie-graph {
height: 400px;
min-height: 400px;
display: block;
}
#solve-percentages-graph {
height: 400px;
min-height: 400px;
display: block;
}
#score-distribution-graph {
min-height: 400px;
display: block;
}

View File

@@ -61,6 +61,6 @@
#challenge-window .form-control:focus {
background-color: transparent;
border-color: #a3d39c;
box-shadow: 0 0 0 0.2rem #a3d39c;
box-shadow: 0 0 0 0.1rem #a3d39c;
transition: background-color 0.3s, border-color 0.3s;
}

View File

@@ -1,4 +1,6 @@
@import "~codemirror/lib/codemirror.css";
.CodeMirror {
@import "includes/easymde.scss";
.CodeMirror.cm-s-default {
font-size: 12px;
border: 1px solid lightgray;
}

View File

@@ -0,0 +1,382 @@
.CodeMirror.cm-s-easymde {
box-sizing: border-box;
height: auto;
border: 1px solid lightgray;
padding: 10px;
font: inherit;
z-index: 0;
word-wrap: break-word;
}
.CodeMirror-scroll {
overflow-y: hidden;
overflow-x: auto;
height: 200px;
}
.editor-toolbar {
position: relative;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
-o-user-select: none;
user-select: none;
padding: 0 10px;
border-top: 1px solid #bbb;
border-left: 1px solid #bbb;
border-right: 1px solid #bbb;
}
.editor-toolbar:after,
.editor-toolbar:before {
display: block;
content: " ";
height: 1px;
}
.editor-toolbar:before {
margin-bottom: 8px;
}
.editor-toolbar:after {
margin-top: 8px;
}
.editor-toolbar.fullscreen {
width: 100%;
height: 50px;
padding-top: 10px;
padding-bottom: 10px;
box-sizing: border-box;
background: #fff;
border: 0;
position: fixed;
top: 0;
left: 0;
opacity: 1;
z-index: 9;
}
.editor-toolbar.fullscreen::before {
width: 20px;
height: 50px;
background: -moz-linear-gradient(
left,
rgba(255, 255, 255, 1) 0%,
rgba(255, 255, 255, 0) 100%
);
background: -webkit-gradient(
linear,
left top,
right top,
color-stop(0%, rgba(255, 255, 255, 1)),
color-stop(100%, rgba(255, 255, 255, 0))
);
background: -webkit-linear-gradient(
left,
rgba(255, 255, 255, 1) 0%,
rgba(255, 255, 255, 0) 100%
);
background: -o-linear-gradient(
left,
rgba(255, 255, 255, 1) 0%,
rgba(255, 255, 255, 0) 100%
);
background: -ms-linear-gradient(
left,
rgba(255, 255, 255, 1) 0%,
rgba(255, 255, 255, 0) 100%
);
background: linear-gradient(
to right,
rgba(255, 255, 255, 1) 0%,
rgba(255, 255, 255, 0) 100%
);
position: fixed;
top: 0;
left: 0;
margin: 0;
padding: 0;
}
.editor-toolbar.fullscreen::after {
width: 20px;
height: 50px;
background: -moz-linear-gradient(
left,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 1) 100%
);
background: -webkit-gradient(
linear,
left top,
right top,
color-stop(0%, rgba(255, 255, 255, 0)),
color-stop(100%, rgba(255, 255, 255, 1))
);
background: -webkit-linear-gradient(
left,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 1) 100%
);
background: -o-linear-gradient(
left,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 1) 100%
);
background: -ms-linear-gradient(
left,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 1) 100%
);
background: linear-gradient(
to right,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 1) 100%
);
position: fixed;
top: 0;
right: 0;
margin: 0;
padding: 0;
}
.editor-toolbar button,
.editor-toolbar .easymde-dropdown {
background: transparent;
display: inline-block;
text-align: center;
text-decoration: none !important;
height: 30px;
margin: 0;
padding: 0;
border: 1px solid transparent;
border-radius: 3px;
cursor: pointer;
}
.editor-toolbar button {
width: 30px;
}
.editor-toolbar button.active,
.editor-toolbar button:hover {
background: #fcfcfc;
border-color: #95a5a6;
}
.editor-toolbar i.separator {
display: inline-block;
width: 0;
border-left: 1px solid #d9d9d9;
border-right: 1px solid #fff;
color: transparent;
text-indent: -10px;
margin: 0 6px;
}
.editor-toolbar button:after {
font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
font-size: 65%;
vertical-align: text-bottom;
position: relative;
top: 2px;
}
.editor-toolbar button.heading-1:after {
content: "1";
}
.editor-toolbar button.heading-2:after {
content: "2";
}
.editor-toolbar button.heading-3:after {
content: "3";
}
.editor-toolbar button.heading-bigger:after {
content: "";
}
.editor-toolbar button.heading-smaller:after {
content: "";
}
.editor-toolbar.disabled-for-preview button:not(.no-disable) {
opacity: 0.6;
pointer-events: none;
}
@media only screen and (max-width: 700px) {
.editor-toolbar i.no-mobile {
display: none;
}
}
.editor-statusbar {
padding: 8px 10px;
font-size: 12px;
color: #959694;
text-align: right;
}
.editor-statusbar span {
display: inline-block;
min-width: 4em;
margin-left: 1em;
}
.editor-statusbar .lines:before {
content: "lines: ";
}
.editor-statusbar .words:before {
content: "words: ";
}
.editor-statusbar .characters:before {
content: "characters: ";
}
.editor-preview-full {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: 7;
overflow: auto;
display: none;
box-sizing: border-box;
}
.editor-preview-side {
position: fixed;
bottom: 0;
width: 50%;
top: 50px;
right: 0;
z-index: 9;
overflow: auto;
display: none;
box-sizing: border-box;
border: 1px solid #ddd;
word-wrap: break-word;
}
.editor-preview-active-side {
display: block;
}
.editor-preview-active {
display: block;
}
.editor-preview {
padding: 10px;
background: #fafafa;
}
.editor-preview > p {
margin-top: 0;
}
.editor-preview pre {
background: #eee;
margin-bottom: 10px;
}
.editor-preview table td,
.editor-preview table th {
border: 1px solid #ddd;
padding: 5px;
}
.cm-s-easymde .cm-tag {
color: #63a35c;
}
.cm-s-easymde .cm-attribute {
color: #795da3;
}
.cm-s-easymde .cm-string {
color: #183691;
}
.cm-s-easymde .cm-header-1 {
font-size: 200%;
line-height: 200%;
}
.cm-s-easymde .cm-header-2 {
font-size: 160%;
line-height: 160%;
}
.cm-s-easymde .cm-header-3 {
font-size: 125%;
line-height: 125%;
}
.cm-s-easymde .cm-header-4 {
font-size: 110%;
line-height: 110%;
}
.cm-s-easymde .cm-comment {
background: rgba(0, 0, 0, 0.05);
border-radius: 2px;
}
.cm-s-easymde .cm-link {
color: #7f8c8d;
}
.cm-s-easymde .cm-url {
color: #aab2b3;
}
.cm-s-easymde .cm-quote {
color: #7f8c8d;
font-style: italic;
}
.editor-toolbar .easymde-dropdown {
position: relative;
background: linear-gradient(
to bottom right,
#fff 0%,
#fff 84%,
#333 50%,
#333 100%
);
border-radius: 0;
border: 1px solid #fff;
}
.editor-toolbar .easymde-dropdown:hover {
background: linear-gradient(
to bottom right,
#fff 0%,
#fff 84%,
#333 50%,
#333 100%
);
}
.easymde-dropdown-content {
display: none;
position: absolute;
background-color: #f9f9f9;
box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.2);
padding: 8px;
z-index: 2;
top: 30px;
}
.easymde-dropdown:active .easymde-dropdown-content,
.easymde-dropdown:focus .easymde-dropdown-content {
display: block;
}

View File

@@ -1,5 +1,6 @@
import $ from "jquery";
import { ezToast } from "core/ezq";
import { ezToast, ezQuery } from "core/ezq";
import { htmlEntities } from "core/utils";
import CTFd from "core/CTFd";
import nunjucks from "nunjucks";
@@ -90,105 +91,85 @@ function renderSubmissionResponse(response, cb) {
}
$(() => {
$(".preview-challenge").click(function(event) {
$(".preview-challenge").click(function(_event) {
window.challenge = new Object();
$.get(CTFd.config.urlRoot + "/api/v1/challenges/" + CHALLENGE_ID, function(
response
) {
const challenge_data = response.data;
challenge_data["solves"] = null;
$.get(
CTFd.config.urlRoot + "/api/v1/challenges/" + window.CHALLENGE_ID,
function(response) {
const challenge_data = response.data;
challenge_data["solves"] = null;
$.getScript(
CTFd.config.urlRoot + challenge_data.type_data.scripts.view,
function() {
$.get(
CTFd.config.urlRoot + challenge_data.type_data.templates.view,
function(template_data) {
$("#challenge-window").empty();
const template = nunjucks.compile(template_data);
window.challenge.data = challenge_data;
window.challenge.preRender();
$.getScript(
CTFd.config.urlRoot + challenge_data.type_data.scripts.view,
function() {
$.get(
CTFd.config.urlRoot + challenge_data.type_data.templates.view,
function(template_data) {
$("#challenge-window").empty();
const template = nunjucks.compile(template_data);
window.challenge.data = challenge_data;
window.challenge.preRender();
challenge_data["description"] = window.challenge.render(
challenge_data["description"]
);
challenge_data["script_root"] = CTFd.config.urlRoot;
challenge_data["description"] = window.challenge.render(
challenge_data["description"]
);
challenge_data["script_root"] = CTFd.config.urlRoot;
$("#challenge-window").append(template.render(challenge_data));
$("#challenge-window").append(template.render(challenge_data));
$(".challenge-solves").click(function(event) {
getsolves($("#challenge-id").val());
});
$(".nav-tabs a").click(function(event) {
event.preventDefault();
$(this).tab("show");
});
$(".nav-tabs a").click(function(event) {
event.preventDefault();
$(this).tab("show");
});
// Handle modal toggling
$("#challenge-window").on("hide.bs.modal", function(event) {
$("#submission-input").removeClass("wrong");
$("#submission-input").removeClass("correct");
$("#incorrect-key").slideUp();
$("#correct-key").slideUp();
$("#already-solved").slideUp();
$("#too-fast").slideUp();
});
// Handle modal toggling
$("#challenge-window").on("hide.bs.modal", function(_event) {
$("#submission-input").removeClass("wrong");
$("#submission-input").removeClass("correct");
$("#incorrect-key").slideUp();
$("#correct-key").slideUp();
$("#already-solved").slideUp();
$("#too-fast").slideUp();
});
$("#submit-key").click(function(event) {
event.preventDefault();
$("#submit-key").addClass("disabled-button");
$("#submit-key").prop("disabled", true);
window.challenge.submit(function(data) {
renderSubmissionResponse(data);
}, true);
// Preview passed as true
});
$("#submit-key").click(function(event) {
event.preventDefault();
$("#submit-key").addClass("disabled-button");
$("#submit-key").prop("disabled", true);
window.challenge.submit(function(data) {
renderSubmissionResponse(data);
}, true);
// Preview passed as true
});
$("#submission-input").keyup(function(event) {
if (event.keyCode == 13) {
$("#submit-key").click();
}
});
$(".input-field").bind({
focus: function() {
$(this)
.parent()
.addClass("input--filled");
$label = $(this).siblings(".input-label");
},
blur: function() {
if ($(this).val() === "") {
$(this)
.parent()
.removeClass("input--filled");
$label = $(this).siblings(".input-label");
$label.removeClass("input--hide");
$("#submission-input").keyup(function(event) {
if (event.keyCode == 13) {
$("#submit-key").click();
}
}
});
});
window.challenge.postRender();
window.location.replace(
window.location.href.split("#")[0] + "#preview"
);
window.challenge.postRender();
window.location.replace(
window.location.href.split("#")[0] + "#preview"
);
$("#challenge-window").modal();
}
);
}
);
});
$("#challenge-window").modal();
}
);
}
);
}
);
});
$(".delete-challenge").click(function(event) {
$(".delete-challenge").click(function(_event) {
ezQuery({
title: "Delete Challenge",
body: "Are you sure you want to delete {0}".format(
"<strong>" + htmlentities(CHALLENGE_NAME) + "</strong>"
"<strong>" + htmlEntities(window.CHALLENGE_NAME) + "</strong>"
),
success: function() {
CTFd.fetch("/api/v1/challenges/" + CHALLENGE_ID, {
CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID, {
method: "DELETE"
}).then(function(response) {
if (response.success) {
@@ -203,7 +184,7 @@ $(() => {
event.preventDefault();
const params = $(event.target).serializeJSON(true);
CTFd.fetch("/api/v1/challenges/" + CHALLENGE_ID, {
CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID, {
method: "PATCH",
credentials: "same-origin",
headers: {

View File

@@ -7,17 +7,17 @@ export function addFile(event) {
event.preventDefault();
let form = event.target;
let data = {
challenge: CHALLENGE_ID,
challenge: window.CHALLENGE_ID,
type: "challenge"
};
helpers.files.upload(form, data, function(response) {
helpers.files.upload(form, data, function(_response) {
setTimeout(function() {
window.location.reload();
}, 700);
});
}
export function deleteFile(event) {
export function deleteFile(_event) {
const file_id = $(this).attr("file-id");
const row = $(this)
.parent()

View File

@@ -29,7 +29,7 @@ export function deleteFlag(event) {
});
}
export function addFlagModal(event) {
export function addFlagModal(_event) {
$.get(CTFd.config.urlRoot + "/api/v1/flags/types", function(response) {
const data = response.data;
const flag_type_select = $("#flags-create-select");
@@ -52,7 +52,7 @@ export function addFlagModal(event) {
$("#flag-edit-modal form").submit(function(event) {
event.preventDefault();
const params = $(this).serializeJSON(true);
params["challenge"] = CHALLENGE_ID;
params["challenge"] = window.CHALLENGE_ID;
CTFd.fetch("/api/v1/flags", {
method: "POST",
credentials: "same-origin",
@@ -65,7 +65,7 @@ export function addFlagModal(event) {
.then(function(response) {
return response.json();
})
.then(function(response) {
.then(function(_response) {
window.location.reload();
});
});

View File

@@ -1,50 +1,18 @@
import $ from "jquery";
import CTFd from "core/CTFd";
import { ezQuery, ezAlert } from "core/ezq";
function hint(id) {
return CTFd.fetch("/api/v1/hints/" + id + "?preview=true", {
method: "GET",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
}
});
}
function loadhint(hintid) {
const md = CTFd.lib.markdown();
hint(hintid).then(function(response) {
if (response.data.content) {
ezAlert({
title: "Hint",
body: md.render(response.data.content),
button: "Got it!"
});
} else {
ezAlert({
title: "Error",
body: "Error loading hint!",
button: "OK"
});
}
});
}
import { ezQuery } from "core/ezq";
export function showHintModal(event) {
event.preventDefault();
$("#hint-edit-modal form")
.find("input, textarea")
.val("");
.val("")
// Trigger a change on the textarea to get codemirror to clone changes in
.trigger("change");
// Markdown Preview
$("#new-hint-edit").on("shown.bs.tab", function(event) {
if (event.target.hash == "#hint-preview") {
const renderer = CTFd.lib.markdown();
const editor_value = $("#hint-write textarea").val();
$(event.target.hash).html(renderer.render(editor_value));
$("#hint-edit-form textarea").each(function(i, e) {
if (e.hasOwnProperty("codemirror")) {
e.codemirror.refresh();
}
});
@@ -68,21 +36,33 @@ export function showEditHintModal(event) {
})
.then(function(response) {
if (response.success) {
$("#hint-edit-form input[name=content],textarea[name=content]").val(
response.data.content
);
$("#hint-edit-form input[name=content],textarea[name=content]")
.val(response.data.content)
// Trigger a change on the textarea to get codemirror to clone changes in
.trigger("change");
$("#hint-edit-modal")
.on("shown.bs.modal", function() {
$("#hint-edit-form textarea").each(function(i, e) {
if (e.hasOwnProperty("codemirror")) {
e.codemirror.refresh();
}
});
})
.on("hide.bs.modal", function() {
$("#hint-edit-form textarea").each(function(i, e) {
$(e)
.val("")
.trigger("change");
if (e.hasOwnProperty("codemirror")) {
e.codemirror.refresh();
}
});
});
$("#hint-edit-form input[name=cost]").val(response.data.cost);
$("#hint-edit-form input[name=id]").val(response.data.id);
// Markdown Preview
$("#new-hint-edit").on("shown.bs.tab", function(event) {
if (event.target.hash == "#hint-preview") {
const renderer = CTFd.lib.markdown();
const editor_value = $("#hint-write textarea").val();
$(event.target.hash).html(renderer.render(editor_value));
}
});
$("#hint-edit-modal").modal();
}
});
@@ -116,7 +96,7 @@ export function deleteHint(event) {
export function editHint(event) {
event.preventDefault();
const params = $(this).serializeJSON(true);
params["challenge"] = CHALLENGE_ID;
params["challenge"] = window.CHALLENGE_ID;
let method = "POST";
let url = "/api/v1/hints";

View File

@@ -1,4 +1,5 @@
import CTFd from "core/CTFd";
import nunjucks from "nunjucks";
import $ from "jquery";
window.challenge = new Object();
@@ -63,7 +64,7 @@ $.get(CTFd.config.urlRoot + "/api/v1/challenges/types", function(response) {
}
});
function createChallenge(event) {
function createChallenge(_event) {
const challenge = $(this)
.find("option:selected")
.data("meta");

View File

@@ -10,15 +10,15 @@ export function addRequirement(event) {
return;
}
CHALLENGE_REQUIREMENTS.prerequisites.push(
window.CHALLENGE_REQUIREMENTS.prerequisites.push(
parseInt(requirements["prerequisite"])
);
const params = {
requirements: CHALLENGE_REQUIREMENTS
requirements: window.CHALLENGE_REQUIREMENTS
};
CTFd.fetch("/api/v1/challenges/" + CHALLENGE_ID, {
CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID, {
method: "PATCH",
credentials: "same-origin",
headers: {
@@ -38,18 +38,18 @@ export function addRequirement(event) {
});
}
export function deleteRequirement(event) {
export function deleteRequirement(_event) {
const challenge_id = $(this).attr("challenge-id");
const row = $(this)
.parent()
.parent();
CHALLENGE_REQUIREMENTS.prerequisites.pop(challenge_id);
window.CHALLENGE_REQUIREMENTS.prerequisites.pop(challenge_id);
const params = {
requirements: CHALLENGE_REQUIREMENTS
requirements: window.CHALLENGE_REQUIREMENTS
};
CTFd.fetch("/api/v1/challenges/" + CHALLENGE_ID, {
CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID, {
method: "PATCH",
credentials: "same-origin",
headers: {

View File

@@ -1,7 +1,7 @@
import $ from "jquery";
import CTFd from "core/CTFd";
export function deleteTag(event) {
export function deleteTag(_event) {
const $elem = $(this);
const tag_id = $elem.attr("tag-id");
@@ -22,7 +22,7 @@ export function addTag(event) {
const tag = $elem.val();
const params = {
value: tag,
challenge: CHALLENGE_ID
challenge: window.CHALLENGE_ID
};
CTFd.api.post_tag_list({}, params).then(response => {

View File

@@ -0,0 +1,332 @@
<template>
<div id="media-modal" class="modal fade" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<div class="container">
<div class="row">
<div class="col-md-12">
<h3 class="text-center">Media Library</h3>
</div>
</div>
</div>
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="modal-header">
<div class="container">
<div class="row mh-100">
<div class="col-md-6" id="media-library-list">
<div
class="media-item-wrapper"
v-for="file in files"
:key="file.id"
>
<a
href="javascript:void(0)"
@click="
selectFile(file);
return false;
"
>
<i
v-bind:class="getIconClass(file.location)"
aria-hidden="true"
></i>
<small class="media-item-title">{{
file.location.split("/").pop()
}}</small>
</a>
</div>
</div>
<div class="col-md-6" id="media-library-details">
<h4 class="text-center">Media Details</h4>
<div id="media-item">
<div class="text-center" id="media-icon">
<div v-if="this.selectedFile">
<div
v-if="
getIconClass(this.selectedFile.location) ===
'far fa-file-image'
"
>
<img
v-bind:src="buildSelectedFileUrl()"
style="max-width: 100%; max-height: 100%; object-fit: contain;"
/>
</div>
<div v-else>
<i
v-bind:class="
`${getIconClass(
this.selectedFile.location
)} fa-4x`
"
aria-hidden="true"
></i>
</div>
</div>
</div>
<br />
<div
class="text-center"
id="media-filename"
v-if="this.selectedFile"
>
<a v-bind:href="buildSelectedFileUrl()" target="_blank">
{{ this.selectedFile.location.split("/").pop() }}
</a>
</div>
<br />
<div class="form-group">
<div v-if="this.selectedFile">
Link:
<input
class="form-control"
type="text"
id="media-link"
v-bind:value="buildSelectedFileUrl()"
readonly
/>
</div>
<div v-else>
Link:
<input
class="form-control"
type="text"
id="media-link"
readonly
/>
</div>
</div>
<div class="form-group text-center">
<div class="row">
<div class="col-md-6">
<button
@click="insertSelectedFile"
class="btn btn-success w-100"
id="media-insert"
data-toggle="tooltip"
data-placement="top"
title="Insert link into editor"
>
Insert
</button>
</div>
<div class="col-md-3">
<button
@click="downloadSelectedFile"
class="btn btn-primary w-100"
id="media-download"
data-toggle="tooltip"
data-placement="top"
title="Download file"
>
<i class="fas fa-download"></i>
</button>
</div>
<div class="col-md-3">
<button
@click="deleteSelectedFile"
class="btn btn-danger w-100"
id="media-delete"
data-toggle="tooltip"
data-placement="top"
title="Delete file"
>
<i class="far fa-trash-alt"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<form id="media-library-upload" enctype="multipart/form-data">
<div class="form-group">
<label for="media-files">
Upload Files
</label>
<input
type="file"
name="file"
id="media-files"
class="form-control-file"
multiple
/>
<sub class="help-block">
Attach multiple files using Control+Click or Cmd+Click.
</sub>
</div>
<input type="hidden" value="page" name="type" />
</form>
</div>
<div class="modal-footer">
<div class="float-right">
<button
@click="uploadChosenFiles"
type="submit"
class="btn btn-primary media-upload-button"
>
Upload
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import CTFd from "core/CTFd";
import { ezQuery, ezToast } from "core/ezq";
import { default as helpers } from "core/helpers";
function get_page_files() {
return CTFd.fetch("/api/v1/files?type=page", {
credentials: "same-origin"
}).then(function(response) {
return response.json();
});
}
export default {
props: {
editor: Object
},
data: function() {
return {
files: [],
selectedFile: null
};
},
methods: {
getPageFiles: function() {
get_page_files().then(response => {
this.files = response.data;
return this.files;
});
},
uploadChosenFiles: function() {
// TODO: We should reduce the need to interact with the DOM directly.
// This looks jank and we should be able to remove it.
let form = document.querySelector("#media-library-upload");
helpers.files.upload(form, {}, _data => {
this.getPageFiles();
});
},
selectFile: function(file) {
this.selectedFile = file;
return this.selectedFile;
},
buildSelectedFileUrl: function() {
return CTFd.config.urlRoot + "/files/" + this.selectedFile.location;
},
deleteSelectedFile: function() {
var file_id = this.selectedFile.id;
if (confirm("Are you sure you want to delete this file?")) {
CTFd.fetch("/api/v1/files/" + file_id, {
method: "DELETE"
}).then(response => {
if (response.status === 200) {
response.json().then(object => {
if (object.success) {
this.getPageFiles();
this.selectedFile = null;
}
});
}
});
}
},
insertSelectedFile: function() {
let editor = this.$props.editor;
if (editor.hasOwnProperty("codemirror")) {
editor = editor.codemirror;
}
let doc = editor.getDoc();
let cursor = doc.getCursor();
let url = this.buildSelectedFileUrl();
let img =
this.getIconClass(this.selectedFile.location) === "far fa-file-image";
let filename = url.split("/").pop();
link = "[{0}]({1})".format(filename, url);
if (img) {
link = "!" + link;
}
doc.replaceRange(link, cursor);
},
downloadSelectedFile: function() {
var link = this.buildSelectedFileUrl();
window.open(link, "_blank");
},
getIconClass: function(filename) {
var mapping = {
// Image Files
png: "far fa-file-image",
jpg: "far fa-file-image",
jpeg: "far fa-file-image",
gif: "far fa-file-image",
bmp: "far fa-file-image",
svg: "far fa-file-image",
// Text Files
txt: "far fa-file-alt",
// Video Files
mov: "far fa-file-video",
mp4: "far fa-file-video",
wmv: "far fa-file-video",
flv: "far fa-file-video",
mkv: "far fa-file-video",
avi: "far fa-file-video",
// PDF Files
pdf: "far fa-file-pdf",
// Audio Files
mp3: "far fa-file-sound",
wav: "far fa-file-sound",
aac: "far fa-file-sound",
// Archive Files
zip: "far fa-file-archive",
gz: "far fa-file-archive",
tar: "far fa-file-archive",
"7z": "far fa-file-archive",
rar: "far fa-file-archive",
// Code Files
py: "far fa-file-code",
c: "far fa-file-code",
cpp: "far fa-file-code",
html: "far fa-file-code",
js: "far fa-file-code",
rb: "far fa-file-code",
go: "far fa-file-code"
};
var ext = filename.split(".").pop();
return mapping[ext] || "far fa-file";
}
},
created() {
return this.getPageFiles();
}
};
</script>

View File

@@ -5,11 +5,11 @@ import "bootstrap/js/dist/tab";
import CTFd from "core/CTFd";
import { htmlEntities } from "core/utils";
import { ezQuery, ezAlert, ezToast } from "core/ezq";
import nunjucks from "nunjucks";
import { default as helpers } from "core/helpers";
import { addFile, deleteFile } from "../challenges/files";
import { addTag, deleteTag } from "../challenges/tags";
import { addRequirement, deleteRequirement } from "../challenges/requirements";
import { bindMarkdownEditors } from "../styles";
import {
showHintModal,
editHint,
@@ -39,7 +39,7 @@ const loadHint = id => {
displayHint(response.data);
return;
}
displayUnlock(id);
// displayUnlock(id);
});
};
@@ -132,42 +132,34 @@ function renderSubmissionResponse(response, cb) {
function loadChalTemplate(challenge) {
CTFd._internal.challenge = {};
$.getScript(CTFd.config.urlRoot + challenge.scripts.view, function() {
$.get(CTFd.config.urlRoot + challenge.templates.create, function(
template_data
) {
const template = nunjucks.compile(template_data);
$("#create-chal-entry-div").html(
template.render({
nonce: CTFd.config.csrfNonce,
script_root: CTFd.config.urlRoot
})
);
let template_data = challenge.create;
$("#create-chal-entry-div").html(template_data);
bindMarkdownEditors();
$.getScript(CTFd.config.urlRoot + challenge.scripts.create, function() {
$("#create-chal-entry-div form").submit(function(event) {
event.preventDefault();
const params = $("#create-chal-entry-div form").serializeJSON();
CTFd.fetch("/api/v1/challenges", {
method: "POST",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify(params)
$.getScript(CTFd.config.urlRoot + challenge.scripts.create, function() {
$("#create-chal-entry-div form").submit(function(event) {
event.preventDefault();
const params = $("#create-chal-entry-div form").serializeJSON();
CTFd.fetch("/api/v1/challenges", {
method: "POST",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify(params)
})
.then(function(response) {
return response.json();
})
.then(function(response) {
return response.json();
})
.then(function(response) {
if (response.success) {
$("#challenge-create-options #challenge_id").val(
response.data.id
);
$("#challenge-create-options").modal();
}
});
});
.then(function(response) {
if (response.success) {
$("#challenge-create-options #challenge_id").val(
response.data.id
);
$("#challenge-create-options").modal();
}
});
});
});
});
@@ -210,7 +202,7 @@ function handleChallengeOptions(event) {
Promise.all([
// Save flag
new Promise(function(resolve, reject) {
new Promise(function(resolve, _reject) {
if (flag_params.content.length == 0) {
resolve();
return;
@@ -228,7 +220,7 @@ function handleChallengeOptions(event) {
});
}),
// Upload files
new Promise(function(resolve, reject) {
new Promise(function(resolve, _reject) {
let form = event.target;
let data = {
challenge: params.challenge_id,
@@ -240,12 +232,12 @@ function handleChallengeOptions(event) {
}
resolve();
})
]).then(responses => {
]).then(_responses => {
save_challenge();
});
}
function createChallenge(event) {
function createChallenge(_event) {
const challenge = $(this)
.find("option:selected")
.data("meta");
@@ -257,113 +249,84 @@ function createChallenge(event) {
}
$(() => {
$(".preview-challenge").click(function(e) {
$(".preview-challenge").click(function(_e) {
window.challenge = new Object();
CTFd._internal.challenge = {};
$.get(CTFd.config.urlRoot + "/api/v1/challenges/" + CHALLENGE_ID, function(
response
) {
const challenge = CTFd._internal.challenge;
var challenge_data = response.data;
challenge_data["solves"] = null;
$.get(
CTFd.config.urlRoot + "/api/v1/challenges/" + window.CHALLENGE_ID,
function(response) {
const challenge = CTFd._internal.challenge;
var challenge_data = response.data;
challenge_data["solves"] = null;
$.getScript(
CTFd.config.urlRoot + challenge_data.type_data.scripts.view,
function() {
$.get(
CTFd.config.urlRoot + challenge_data.type_data.templates.view,
function(template_data) {
$("#challenge-window").empty();
var template = nunjucks.compile(template_data);
// window.challenge.data = challenge_data;
// window.challenge.preRender();
challenge.data = challenge_data;
challenge.preRender();
$.getScript(
CTFd.config.urlRoot + challenge_data.type_data.scripts.view,
function() {
$("#challenge-window").empty();
challenge_data["description"] = challenge.render(
challenge_data["description"]
);
challenge_data["script_root"] = CTFd.config.urlRoot;
$("#challenge-window").append(challenge_data.view);
$("#challenge-window").append(template.render(challenge_data));
$("#challenge-window #challenge-input").addClass("form-control");
$("#challenge-window #challenge-submit").addClass(
"btn btn-md btn-outline-secondary float-right"
);
$(".challenge-solves").click(function(e) {
getsolves($("#challenge-id").val());
});
$(".nav-tabs a").click(function(e) {
e.preventDefault();
$(this).tab("show");
});
$(".challenge-solves").hide();
$(".nav-tabs a").click(function(e) {
e.preventDefault();
$(this).tab("show");
});
// Handle modal toggling
$("#challenge-window").on("hide.bs.modal", function(event) {
$("#submission-input").removeClass("wrong");
$("#submission-input").removeClass("correct");
$("#incorrect-key").slideUp();
$("#correct-key").slideUp();
$("#already-solved").slideUp();
$("#too-fast").slideUp();
});
// Handle modal toggling
$("#challenge-window").on("hide.bs.modal", function(_event) {
$("#challenge-input").removeClass("wrong");
$("#challenge-input").removeClass("correct");
$("#incorrect-key").slideUp();
$("#correct-key").slideUp();
$("#already-solved").slideUp();
$("#too-fast").slideUp();
});
$(".load-hint").on("click", function(event) {
loadHint($(this).data("hint-id"));
});
$(".load-hint").on("click", function(_event) {
loadHint($(this).data("hint-id"));
});
$("#submit-key").click(function(e) {
e.preventDefault();
$("#submit-key").addClass("disabled-button");
$("#submit-key").prop("disabled", true);
CTFd._internal.challenge
.submit(true)
.then(renderSubmissionResponse);
// Preview passed as true
});
$("#challenge-submit").click(function(e) {
e.preventDefault();
$("#challenge-submit").addClass("disabled-button");
$("#challenge-submit").prop("disabled", true);
CTFd._internal.challenge
.submit(true)
.then(renderSubmissionResponse);
// Preview passed as true
});
$("#submission-input").keyup(function(event) {
if (event.keyCode == 13) {
$("#submit-key").click();
}
});
$("#challenge-input").keyup(function(event) {
if (event.keyCode == 13) {
$("#challenge-submit").click();
}
});
$(".input-field").bind({
focus: function() {
$(this)
.parent()
.addClass("input--filled");
$label = $(this).siblings(".input-label");
},
blur: function() {
if ($(this).val() === "") {
$(this)
.parent()
.removeClass("input--filled");
$label = $(this).siblings(".input-label");
$label.removeClass("input--hide");
}
}
});
challenge.postRender();
window.location.replace(
window.location.href.split("#")[0] + "#preview"
);
challenge.postRender();
window.location.replace(
window.location.href.split("#")[0] + "#preview"
);
$("#challenge-window").modal();
}
);
}
);
});
$("#challenge-window").modal();
}
);
}
);
});
$(".delete-challenge").click(function(e) {
$(".delete-challenge").click(function(_e) {
ezQuery({
title: "Delete Challenge",
body: "Are you sure you want to delete {0}".format(
"<strong>" + htmlEntities(CHALLENGE_NAME) + "</strong>"
"<strong>" + htmlEntities(window.CHALLENGE_NAME) + "</strong>"
),
success: function() {
CTFd.fetch("/api/v1/challenges/" + CHALLENGE_ID, {
CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID, {
method: "DELETE"
})
.then(function(response) {
@@ -382,7 +345,7 @@ $(() => {
e.preventDefault();
var params = $(e.target).serializeJSON(true);
CTFd.fetch("/api/v1/challenges/" + CHALLENGE_ID + "/flags", {
CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID + "/flags", {
method: "GET",
credentials: "same-origin",
headers: {
@@ -395,7 +358,7 @@ $(() => {
})
.then(function(response) {
let update_challenge = function() {
CTFd.fetch("/api/v1/challenges/" + CHALLENGE_ID, {
CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID, {
method: "PATCH",
credentials: "same-origin",
headers: {

View File

@@ -3,7 +3,7 @@ import CTFd from "core/CTFd";
import $ from "jquery";
import { ezAlert, ezQuery } from "core/ezq";
function deleteSelectedChallenges(event) {
function deleteSelectedChallenges(_event) {
let challengeIDs = $("input[data-challenge-id]:checked").map(function() {
return $(this).data("challenge-id");
});
@@ -21,14 +21,14 @@ function deleteSelectedChallenges(event) {
})
);
}
Promise.all(reqs).then(responses => {
Promise.all(reqs).then(_responses => {
window.location.reload();
});
}
});
}
function bulkEditChallenges(event) {
function bulkEditChallenges(_event) {
let challengeIDs = $("input[data-challenge-id]:checked").map(function() {
return $(this).data("challenge-id");
});
@@ -67,7 +67,7 @@ function bulkEditChallenges(event) {
})
);
}
Promise.all(reqs).then(responses => {
Promise.all(reqs).then(_responses => {
window.location.reload();
});
}

View File

@@ -6,7 +6,7 @@ import moment from "moment-timezone";
import CTFd from "core/CTFd";
import { default as helpers } from "core/helpers";
import $ from "jquery";
import { ezQuery, ezProgressBar } from "core/ezq";
import { ezQuery, ezProgressBar, ezAlert } from "core/ezq";
import CodeMirror from "codemirror";
import "codemirror/mode/htmlmixed/htmlmixed.js";
@@ -110,7 +110,7 @@ function updateConfigs(event) {
}
});
CTFd.api.patch_config_list({}, params).then(response => {
CTFd.api.patch_config_list({}, params).then(_response => {
window.location.reload();
});
}
@@ -154,7 +154,7 @@ function removeLogo() {
};
CTFd.api
.patch_config({ configKey: "ctf_logo" }, params)
.then(response => {
.then(_response => {
window.location.reload();
});
}
@@ -182,7 +182,6 @@ function importConfig(event) {
contentType: false,
statusCode: {
500: function(resp) {
console.log(resp.responseText);
alert(resp.responseText);
}
},
@@ -199,7 +198,7 @@ function importConfig(event) {
};
return xhr;
},
success: function(data) {
success: function(_data) {
pg = ezProgressBar({
target: pg,
width: 100
@@ -216,7 +215,6 @@ function importConfig(event) {
function exportConfig(event) {
event.preventDefault();
const href = CTFd.config.urlRoot + "/admin/export";
window.location.href = $(this).attr("href");
}
@@ -251,6 +249,52 @@ $(() => {
}
);
const theme_settings_editor = CodeMirror.fromTextArea(
document.getElementById("theme-settings"),
{
lineNumbers: true,
lineWrapping: true,
mode: { name: "javascript", json: true }
}
);
// Handle refreshing codemirror when switching tabs.
// Better than the autorefresh approach b/c there's no flicker
$("a[href='#theme']").on("shown.bs.tab", function(_e) {
theme_header_editor.refresh();
theme_footer_editor.refresh();
theme_settings_editor.refresh();
});
$("#theme-settings-modal form").submit(function(e) {
e.preventDefault();
theme_settings_editor
.getDoc()
.setValue(JSON.stringify($(this).serializeJSON(), null, 2));
$("#theme-settings-modal").modal("hide");
});
$("#theme-settings-button").click(function() {
let form = $("#theme-settings-modal form");
let data = JSON.parse(theme_settings_editor.getValue());
$.each(data, function(key, value) {
var ctrl = form.find(`[name='${key}']`);
switch (ctrl.prop("type")) {
case "radio":
case "checkbox":
ctrl.each(function() {
if ($(this).attr("value") == value) {
$(this).attr("checked", value);
}
});
break;
default:
ctrl.val(value);
}
});
$("#theme-settings-modal").modal();
});
insertTimezones($("#start-timezone"));
insertTimezones($("#end-timezone"));
insertTimezones($("#freeze-timezone"));

View File

@@ -1,155 +1,11 @@
import "./main";
import { showMediaLibrary } from "../styles";
import "core/utils";
import $ from "jquery";
import CTFd from "core/CTFd";
import { default as helpers } from "core/helpers";
import CodeMirror from "codemirror";
import "codemirror/mode/htmlmixed/htmlmixed.js";
import { ezQuery, ezToast } from "core/ezq";
function get_filetype_icon_class(filename) {
var mapping = {
// Image Files
png: "fa-file-image",
jpg: "fa-file-image",
jpeg: "fa-file-image",
gif: "fa-file-image",
bmp: "fa-file-image",
svg: "fa-file-image",
// Text Files
txt: "fa-file-alt",
// Video Files
mov: "fa-file-video",
mp4: "fa-file-video",
wmv: "fa-file-video",
flv: "fa-file-video",
mkv: "fa-file-video",
avi: "fa-file-video",
// PDF Files
pdf: "fa-file-pdf",
// Audio Files
mp3: "fa-file-sound",
wav: "fa-file-sound",
aac: "fa-file-sound",
// Archive Files
zip: "fa-file-archive",
gz: "fa-file-archive",
tar: "fa-file-archive",
"7z": "fa-file-archive",
rar: "fa-file-archive",
// Code Files
py: "fa-file-code",
c: "fa-file-code",
cpp: "fa-file-code",
html: "fa-file-code",
js: "fa-file-code",
rb: "fa-file-code",
go: "fa-file-code"
};
var ext = filename.split(".").pop();
return mapping[ext];
}
function get_page_files() {
return CTFd.fetch("/api/v1/files?type=page", {
credentials: "same-origin"
}).then(function(response) {
return response.json();
});
}
function show_files(data) {
var list = $("#media-library-list");
list.empty();
for (var i = 0; i < data.length; i++) {
var f = data[i];
var fname = f.location.split("/").pop();
var ext = get_filetype_icon_class(f.location);
var wrapper = $("<div>").attr("class", "media-item-wrapper");
var link = $("<a>");
link.attr("href", "##");
if (ext === undefined) {
link.append(
'<i class="far fa-file" aria-hidden="true"></i> '.format(ext)
);
} else {
link.append('<i class="far {0}" aria-hidden="true"></i> '.format(ext));
}
link.append(
$("<small>")
.attr("class", "media-item-title")
.text(fname)
);
link.click(function(e) {
var media_div = $(this).parent();
var icon = $(this).find("i")[0];
var f_loc = media_div.attr("data-location");
var fname = media_div.attr("data-filename");
var f_id = media_div.attr("data-id");
$("#media-delete").attr("data-id", f_id);
$("#media-link").val(f_loc);
$("#media-filename").html(
$("<a>")
.attr("href", f_loc)
.attr("target", "_blank")
.text(fname)
);
$("#media-icon").empty();
if ($(icon).hasClass("fa-file-image")) {
$("#media-icon").append(
$("<img>")
.attr("src", f_loc)
.css({
"max-width": "100%",
"max-height": "100%",
"object-fit": "contain"
})
);
} else {
// icon is empty so we need to pull outerHTML
var copy_icon = $(icon).clone();
$(copy_icon).addClass("fa-4x");
$("#media-icon").append(copy_icon);
}
$("#media-item").show();
});
wrapper.append(link);
wrapper.attr("data-location", CTFd.config.urlRoot + "/files/" + f.location);
wrapper.attr("data-id", f.id);
wrapper.attr("data-filename", fname);
list.append(wrapper);
}
}
function refresh_files(cb) {
get_page_files().then(function(response) {
var data = response.data;
show_files(data);
if (cb) {
cb();
}
});
}
function insert_at_cursor(editor, text) {
var doc = editor.getDoc();
var cursor = doc.getCursor();
doc.replaceRange(text, cursor);
}
import { ezToast } from "core/ezq";
function submit_form() {
// Save the CodeMirror data to the Textarea
@@ -158,8 +14,9 @@ function submit_form() {
var target = "/api/v1/pages";
var method = "POST";
if (params.id) {
target += "/" + params.id;
let part = window.location.pathname.split("/").pop();
if (part !== "new") {
target += "/" + part;
method = "PATCH";
}
@@ -189,18 +46,12 @@ function submit_form() {
}
function preview_page() {
editor.save(); // Save the CodeMirror data to the Textarea
window.editor.save(); // Save the CodeMirror data to the Textarea
$("#page-edit").attr("action", CTFd.config.urlRoot + "/admin/pages/preview");
$("#page-edit").attr("target", "_blank");
$("#page-edit").submit();
}
function upload_media() {
helpers.files.upload($("#media-library-upload"), {}, function(data) {
refresh_files();
});
}
$(() => {
window.editor = CodeMirror.fromTextArea(
document.getElementById("admin-pages-editor"),
@@ -212,55 +63,8 @@ $(() => {
}
);
$("#media-insert").click(function(e) {
var tag = "";
try {
tag = $("#media-icon")
.children()[0]
.nodeName.toLowerCase();
} catch (err) {
tag = "";
}
var link = $("#media-link").val();
var fname = $("#media-filename").text();
var entry = null;
if (tag === "img") {
entry = "![{0}]({1})".format(fname, link);
} else {
entry = "[{0}]({1})".format(fname, link);
}
insert_at_cursor(editor, entry);
});
$("#media-download").click(function(e) {
var link = $("#media-link").val();
window.open(link, "_blank");
});
$("#media-delete").click(function(e) {
var file_id = $(this).attr("data-id");
ezQuery({
title: "Delete File?",
body: "Are you sure you want to delete this file?",
success: function() {
CTFd.fetch("/api/v1/files/" + file_id, {
method: "DELETE",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
}
}).then(function(response) {
if (response.status === 200) {
response.json().then(function(object) {
if (object.success) {
refresh_files();
}
});
}
});
}
});
$("#media-button").click(function(_e) {
showMediaLibrary(window.editor);
});
$("#save-page").click(function(e) {
@@ -268,17 +72,6 @@ $(() => {
submit_form();
});
$("#media-button").click(function() {
$("#media-library-list").empty();
refresh_files(function() {
$("#media-modal").modal();
});
});
$(".media-upload-button").click(function() {
upload_media();
});
$(".preview-page").click(function() {
preview_page();
});

View File

@@ -3,7 +3,7 @@ import CTFd from "core/CTFd";
import $ from "jquery";
import { ezQuery } from "core/ezq";
function deleteSelectedUsers(event) {
function deleteSelectedUsers(_event) {
let pageIDs = $("input[data-page-id]:checked").map(function() {
return $(this).data("page-id");
});
@@ -21,7 +21,7 @@ function deleteSelectedUsers(event) {
})
);
}
Promise.all(reqs).then(responses => {
Promise.all(reqs).then(_responses => {
window.location.reload();
});
}

View File

@@ -1,7 +1,7 @@
import "./main";
import CTFd from "core/CTFd";
import $ from "jquery";
import { ezAlert, ezQuery } from "core/ezq";
import { ezAlert } from "core/ezq";
const api_func = {
users: (x, y) => CTFd.api.patch_user_public({ userId: x }, y),
@@ -46,12 +46,12 @@ function toggleSelectedAccounts(accountIDs, action) {
for (var accId of accountIDs) {
reqs.push(api_func[CTFd.config.userMode](accId, params));
}
Promise.all(reqs).then(responses => {
Promise.all(reqs).then(_responses => {
window.location.reload();
});
}
function bulkToggleAccounts(event) {
function bulkToggleAccounts(_event) {
let accountIDs = $("input[data-account-id]:checked").map(function() {
return $(this).data("account-id");
});

View File

@@ -2,28 +2,16 @@ import "./main";
import "core/utils";
import CTFd from "core/CTFd";
import $ from "jquery";
import Plotly from "plotly.js-basic-dist";
import { createGraph, updateGraph } from "core/graphs";
import echarts from "echarts/dist/echarts-en.common";
import { colorHash } from "core/utils";
const graph_configs = {
"#solves-graph": {
layout: annotations => ({
title: "Solve Counts",
annotations: annotations,
xaxis: {
title: "Challenge Name"
},
yaxis: {
title: "Amount of Solves"
}
}),
fn: () => "CTFd_solves_" + new Date().toISOString().slice(0, 19),
data: () => CTFd.api.get_challenge_solve_statistics(),
format: response => {
const data = response.data;
const chals = [];
const counts = [];
const annotations = [];
const solves = {};
for (let c = 0; c < data.length; c++) {
solves[data[c]["id"]] = {
@@ -39,63 +27,155 @@ const graph_configs = {
$.each(solves_order, function(key, value) {
chals.push(solves[value].name);
counts.push(solves[value].solves);
const result = {
x: solves[value].name,
y: solves[value].solves,
text: solves[value].solves,
xanchor: "center",
yanchor: "bottom",
showarrow: false
};
annotations.push(result);
});
return [
{
type: "bar",
x: chals,
y: counts,
text: counts,
orientation: "v"
const option = {
title: {
left: "center",
text: "Solve Counts"
},
annotations
];
tooltip: {
trigger: "item"
},
toolbox: {
show: true,
feature: {
mark: { show: true },
dataView: { show: true, readOnly: false },
magicType: { show: true, type: ["line", "bar"] },
restore: { show: true },
saveAsImage: { show: true }
}
},
xAxis: {
name: "Solve Count",
nameLocation: "middle",
type: "value"
},
yAxis: {
name: "Challenge Name",
nameLocation: "middle",
nameGap: 60,
type: "category",
data: chals,
axisLabel: {
interval: 0,
rotate: 0 //If the label names are too long you can manage this by rotating the label.
}
},
dataZoom: [
{
id: "dataZoomY",
type: "slider",
yAxisIndex: [0],
filterMode: "empty"
}
],
series: [
{
itemStyle: { normal: { color: "#1f76b4" } },
data: counts,
type: "bar"
}
]
};
return option;
}
},
"#keys-pie-graph": {
layout: () => ({
title: "Submission Percentages"
}),
fn: () => "CTFd_submissions_" + new Date().toISOString().slice(0, 19),
data: () => CTFd.api.get_submission_property_counts({ column: "type" }),
format: response => {
const data = response.data;
const solves = data["correct"];
const fails = data["incorrect"];
return [
{
values: [solves, fails],
labels: ["Correct", "Incorrect"],
marker: {
colors: ["rgb(0, 209, 64)", "rgb(207, 38, 0)"]
},
text: ["Solves", "Fails"],
hole: 0.4,
type: "pie"
let option = {
title: {
left: "center",
text: "Submission Percentages"
},
null
];
tooltip: {
trigger: "item"
},
toolbox: {
show: true,
feature: {
dataView: { show: true, readOnly: false },
saveAsImage: {}
}
},
legend: {
orient: "horizontal",
bottom: 0,
data: ["Fails", "Solves"]
},
series: [
{
name: "Submission Percentages",
type: "pie",
radius: ["30%", "50%"],
avoidLabelOverlap: false,
label: {
show: false,
position: "center"
},
itemStyle: {
normal: {
label: {
show: true,
formatter: function(data) {
return `${data.name} - ${data.value} (${data.percent}%)`;
}
},
labelLine: {
show: true
}
},
emphasis: {
label: {
show: true,
position: "center",
textStyle: {
fontSize: "14",
fontWeight: "normal"
}
}
}
},
emphasis: {
label: {
show: true,
fontSize: "30",
fontWeight: "bold"
}
},
labelLine: {
show: false
},
data: [
{
value: fails,
name: "Fails",
itemStyle: { color: "rgb(207, 38, 0)" }
},
{
value: solves,
name: "Solves",
itemStyle: { color: "rgb(0, 209, 64)" }
}
]
}
]
};
return option;
}
},
"#categories-pie-graph": {
layout: () => ({
title: "Category Breakdown"
}),
data: () => CTFd.api.get_challenge_property_counts({ column: "category" }),
fn: () => "CTFd_categories_" + new Date().toISOString().slice(0, 19),
format: response => {
const data = response.data;
@@ -114,15 +194,84 @@ const graph_configs = {
count.push(data[i].count);
}
return [
{
values: count,
labels: categories,
hole: 0.4,
type: "pie"
let option = {
title: {
left: "center",
text: "Category Breakdown"
},
null
];
tooltip: {
trigger: "item"
},
toolbox: {
show: true,
feature: {
dataView: { show: true, readOnly: false },
saveAsImage: {}
}
},
legend: {
orient: "horizontal",
bottom: 0,
data: []
},
series: [
{
name: "Category Breakdown",
type: "pie",
radius: ["30%", "50%"],
avoidLabelOverlap: false,
label: {
show: false,
position: "center"
},
itemStyle: {
normal: {
label: {
show: true,
formatter: function(data) {
return `${data.name} - ${data.value} (${data.percent}%)`;
}
},
labelLine: {
show: true
}
},
emphasis: {
label: {
show: true,
position: "center",
textStyle: {
fontSize: "14",
fontWeight: "normal"
}
}
}
},
emphasis: {
label: {
show: true,
fontSize: "30",
fontWeight: "bold"
}
},
labelLine: {
show: false
},
data: []
}
]
};
categories.forEach((category, index) => {
option.legend.data.push(category);
option.series[0].data.push({
value: count[index],
name: category,
itemStyle: { color: colorHash(category) }
});
});
return option;
}
},
@@ -142,8 +291,6 @@ const graph_configs = {
annotations: annotations
}),
data: () => CTFd.api.get_challenge_solve_percentages(),
fn: () =>
"CTFd_challenge_percentages_" + new Date().toISOString().slice(0, 19),
format: response => {
const data = response.data;
@@ -167,15 +314,61 @@ const graph_configs = {
annotations.push(result);
}
return [
{
type: "bar",
x: names,
y: percents,
orientation: "v"
const option = {
title: {
left: "center",
text: "Solve Percentages per Challenge"
},
annotations
];
tooltip: {
trigger: "item",
formatter: function(data) {
return `${data.name} - ${(Math.round(data.value * 10) / 10).toFixed(
1
)}%`;
}
},
toolbox: {
show: true,
feature: {
mark: { show: true },
dataView: { show: true, readOnly: false },
magicType: { show: true, type: ["line", "bar"] },
restore: { show: true },
saveAsImage: { show: true }
}
},
xAxis: {
name: "Challenge Name",
nameGap: 40,
nameLocation: "middle",
type: "category",
data: names,
axisLabel: {
interval: 0,
rotate: 50
}
},
yAxis: {
name: "Percentage of {0} (%)".format(
CTFd.config.userMode.charAt(0).toUpperCase() +
CTFd.config.userMode.slice(1)
),
nameGap: 50,
nameLocation: "middle",
type: "value",
min: 0,
max: 100
},
series: [
{
itemStyle: { normal: { color: "#1f76b4" } },
data: percents,
type: "bar"
}
]
};
return option;
}
},
@@ -201,8 +394,6 @@ const graph_configs = {
) {
return response.json();
}),
fn: () =>
"CTFd_score_distribution_" + new Date().toISOString().slice(0, 19),
format: response => {
const data = response.data.brackets;
const keys = [];
@@ -221,36 +412,73 @@ const graph_configs = {
start = key;
});
return [
{
type: "bar",
x: brackets,
y: sizes,
orientation: "v"
}
];
const option = {
title: {
left: "center",
text: "Score Distribution"
},
tooltip: {
trigger: "item"
},
toolbox: {
show: true,
feature: {
mark: { show: true },
dataView: { show: true, readOnly: false },
magicType: { show: true, type: ["line", "bar"] },
restore: { show: true },
saveAsImage: { show: true }
}
},
xAxis: {
name: "Score Bracket",
nameGap: 40,
nameLocation: "middle",
type: "category",
data: brackets
},
yAxis: {
name: "Number of {0}".format(
CTFd.config.userMode.charAt(0).toUpperCase() +
CTFd.config.userMode.slice(1)
),
nameGap: 50,
nameLocation: "middle",
type: "value"
},
series: [
{
itemStyle: { normal: { color: "#1f76b4" } },
data: sizes,
type: "bar"
}
]
};
return option;
}
}
};
const config = {
displaylogo: false,
responsive: true
};
const createGraphs = () => {
for (let key in graph_configs) {
const cfg = graph_configs[key];
const $elem = $(key);
$elem.empty();
$elem[0].fn = cfg.fn();
let chart = echarts.init(document.querySelector(key));
cfg
.data()
.then(cfg.format)
.then(([data, annotations]) => {
Plotly.newPlot($elem[0], [data], cfg.layout(annotations), config);
.then(option => {
chart.setOption(option);
$(window).on("resize", function() {
if (chart != null && chart != undefined) {
chart.resize();
}
});
});
}
};
@@ -258,13 +486,12 @@ const createGraphs = () => {
function updateGraphs() {
for (let key in graph_configs) {
const cfg = graph_configs[key];
const $elem = $(key);
let chart = echarts.init(document.querySelector(key));
cfg
.data()
.then(cfg.format)
.then(([data, annotations]) => {
// FIXME: Pass annotations
Plotly.react($elem[0], [data], cfg.layout(annotations), config);
.then(option => {
chart.setOption(option);
});
}
}

View File

@@ -4,7 +4,7 @@ import $ from "jquery";
import { htmlEntities } from "core/utils";
import { ezQuery } from "core/ezq";
function deleteCorrectSubmission(event) {
function deleteCorrectSubmission(_event) {
const key_id = $(this).data("submission-id");
const $elem = $(this)
.parent()
@@ -40,7 +40,7 @@ function deleteCorrectSubmission(event) {
});
}
function deleteSelectedSubmissions(event) {
function deleteSelectedSubmissions(_event) {
let submissionIDs = $("input[data-submission-id]:checked").map(function() {
return $(this).data("submission-id");
});
@@ -54,7 +54,7 @@ function deleteSelectedSubmissions(event) {
for (var subId of submissionIDs) {
reqs.push(CTFd.api.delete_submission({ submissionId: subId }));
}
Promise.all(reqs).then(responses => {
Promise.all(reqs).then(_responses => {
window.location.reload();
});
}

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