mirror of
https://github.com/aljazceru/CTFd.git
synced 2025-12-17 05:54:19 +01:00
3.0.0a1 (#1523)
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:
@@ -13,5 +13,6 @@ module.exports = {
|
|||||||
"sourceType": "module"
|
"sourceType": "module"
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
|
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
7
.github/ISSUE_TEMPLATE.md
vendored
7
.github/ISSUE_TEMPLATE.md
vendored
@@ -6,9 +6,9 @@ If this is a feature request please describe the behavior that you'd like to see
|
|||||||
|
|
||||||
**Environment**:
|
**Environment**:
|
||||||
|
|
||||||
- CTFd Version/Commit:
|
- CTFd Version/Commit:
|
||||||
- Operating System:
|
- Operating System:
|
||||||
- Web Browser and Version:
|
- Web Browser and Version:
|
||||||
|
|
||||||
**What happened?**
|
**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**
|
**How to reproduce your issue**
|
||||||
|
|
||||||
**Any associated stack traces or error logs**
|
**Any associated stack traces or error logs**
|
||||||
|
|
||||||
|
|||||||
43
.github/workflows/lint.yml
vendored
Normal file
43
.github/workflows/lint.yml
vendored
Normal 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
49
.github/workflows/mysql.yml
vendored
Normal 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
58
.github/workflows/postgres.yml
vendored
Normal 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
41
.github/workflows/sqlite.yml
vendored
Normal 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
2
.gitignore
vendored
@@ -36,6 +36,7 @@ pip-delete-this-directory.txt
|
|||||||
htmlcov/
|
htmlcov/
|
||||||
.tox/
|
.tox/
|
||||||
.coverage
|
.coverage
|
||||||
|
.coverage.*
|
||||||
.cache
|
.cache
|
||||||
nosetests.xml
|
nosetests.xml
|
||||||
coverage.xml
|
coverage.xml
|
||||||
@@ -57,6 +58,7 @@ target/
|
|||||||
|
|
||||||
*.db
|
*.db
|
||||||
*.log
|
*.log
|
||||||
|
*.log.*
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
CTFd/static/uploads
|
CTFd/static/uploads
|
||||||
|
|||||||
7
.isort.cfg
Normal file
7
.isort.cfg
Normal 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
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
language: python
|
language: python
|
||||||
dist: xenial
|
dist: bionic
|
||||||
cache:
|
cache:
|
||||||
- pip
|
- pip
|
||||||
- yarn
|
- yarn
|
||||||
services:
|
services:
|
||||||
- mysql
|
- mysql
|
||||||
- postgresql
|
|
||||||
- redis-server
|
- redis-server
|
||||||
addons:
|
addons:
|
||||||
apt:
|
apt:
|
||||||
@@ -17,21 +16,16 @@ addons:
|
|||||||
env:
|
env:
|
||||||
- TESTING_DATABASE_URL='mysql+pymysql://root@localhost/ctfd'
|
- TESTING_DATABASE_URL='mysql+pymysql://root@localhost/ctfd'
|
||||||
- TESTING_DATABASE_URL='sqlite://'
|
- TESTING_DATABASE_URL='sqlite://'
|
||||||
- TESTING_DATABASE_URL='postgres://postgres@localhost/ctfd'
|
|
||||||
python:
|
python:
|
||||||
- 2.7
|
|
||||||
- 3.6
|
- 3.6
|
||||||
before_install:
|
before_install:
|
||||||
- sudo rm -f /etc/boto.cfg
|
- sudo rm -f /etc/boto.cfg
|
||||||
- export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
|
- export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
|
||||||
- export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
- export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
||||||
- python3.6 -m pip install black==19.3b0
|
|
||||||
install:
|
install:
|
||||||
- pip install -r development.txt
|
- pip install -r development.txt
|
||||||
- yarn install --non-interactive
|
- yarn install --non-interactive
|
||||||
- yarn global add prettier@1.17.0
|
- yarn global add prettier@1.17.0
|
||||||
before_script:
|
|
||||||
- psql -c 'create database ctfd;' -U postgres
|
|
||||||
script:
|
script:
|
||||||
- make lint
|
- make lint
|
||||||
- make test
|
- make test
|
||||||
|
|||||||
1658
CHANGELOG.md
1658
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -2,19 +2,19 @@
|
|||||||
|
|
||||||
#### **Did you find a bug?**
|
#### **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?**
|
#### **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?**
|
#### **Did you fix whitespace, format code, or make a purely cosmetic patch?**
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import sys
|
|||||||
import weakref
|
import weakref
|
||||||
from distutils.version import StrictVersion
|
from distutils.version import StrictVersion
|
||||||
|
|
||||||
|
import jinja2
|
||||||
from flask import Flask, Request
|
from flask import Flask, Request
|
||||||
from flask_migrate import upgrade
|
from flask_migrate import upgrade
|
||||||
from jinja2 import FileSystemLoader
|
from jinja2 import FileSystemLoader
|
||||||
from jinja2.sandbox import SandboxedEnvironment
|
from jinja2.sandbox import SandboxedEnvironment
|
||||||
from six.moves import input
|
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
from werkzeug.utils import cached_property
|
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.sessions import CachingSessionInterface
|
||||||
from CTFd.utils.updates import update_check
|
from CTFd.utils.updates import update_check
|
||||||
|
|
||||||
# Hack to support Unicode in Python 2 properly
|
__version__ = "3.0.0a1"
|
||||||
if sys.version_info[0] < 3:
|
|
||||||
reload(sys) # noqa: F821
|
|
||||||
sys.setdefaultencoding("utf-8")
|
|
||||||
|
|
||||||
__version__ = "2.5.0"
|
|
||||||
|
|
||||||
|
|
||||||
class CTFdRequest(Request):
|
class CTFdRequest(Request):
|
||||||
@@ -129,7 +124,7 @@ def confirm_upgrade():
|
|||||||
print("/*\\ CTFd has updated and must update the database! /*\\")
|
print("/*\\ CTFd has updated and must update the database! /*\\")
|
||||||
print("/*\\ Please backup your database before proceeding! /*\\")
|
print("/*\\ Please backup your database before proceeding! /*\\")
|
||||||
print("/*\\ CTFd maintainers are not responsible for any data loss! /*\\")
|
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
|
return True
|
||||||
else:
|
else:
|
||||||
print("/*\\ Ignored database migrations... /*\\")
|
print("/*\\ Ignored database migrations... /*\\")
|
||||||
@@ -148,10 +143,19 @@ def create_app(config="CTFd.config.Config"):
|
|||||||
with app.app_context():
|
with app.app_context():
|
||||||
app.config.from_object(config)
|
app.config.from_object(config)
|
||||||
|
|
||||||
theme_loader = ThemeLoader(
|
app.theme_loader = ThemeLoader(
|
||||||
os.path.join(app.root_path, "themes"), followlinks=True
|
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
|
from CTFd.models import ( # noqa: F401
|
||||||
db,
|
db,
|
||||||
@@ -215,16 +219,10 @@ def create_app(config="CTFd.config.Config"):
|
|||||||
if reverse_proxy:
|
if reverse_proxy:
|
||||||
if type(reverse_proxy) is str and "," in reverse_proxy:
|
if type(reverse_proxy) is str and "," in reverse_proxy:
|
||||||
proxyfix_args = [int(i) for i in reverse_proxy.split(",")]
|
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:
|
else:
|
||||||
app.wsgi_app = ProxyFix(
|
app.wsgi_app = ProxyFix(
|
||||||
app.wsgi_app,
|
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1
|
||||||
num_proxies=None,
|
|
||||||
x_for=1,
|
|
||||||
x_proto=1,
|
|
||||||
x_host=1,
|
|
||||||
x_port=1,
|
|
||||||
x_prefix=1,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
version = utils.get_config("ctf_version")
|
version = utils.get_config("ctf_version")
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import csv
|
import csv
|
||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
|
from io import BytesIO, StringIO
|
||||||
|
|
||||||
import six
|
|
||||||
from flask import Blueprint, abort
|
from flask import Blueprint, abort
|
||||||
from flask import current_app as app
|
from flask import current_app as app
|
||||||
from flask import (
|
from flask import (
|
||||||
@@ -14,7 +14,18 @@ from flask import (
|
|||||||
url_for,
|
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 (
|
from CTFd.models import (
|
||||||
Awards,
|
Awards,
|
||||||
Challenges,
|
Challenges,
|
||||||
@@ -40,17 +51,6 @@ from CTFd.utils.security.auth import logout_user
|
|||||||
from CTFd.utils.uploads import delete_file
|
from CTFd.utils.uploads import delete_file
|
||||||
from CTFd.utils.user import is_admin
|
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"])
|
@admin.route("/admin", methods=["GET"])
|
||||||
def view():
|
def view():
|
||||||
@@ -126,7 +126,7 @@ def export_csv():
|
|||||||
if model is None:
|
if model is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
temp = six.StringIO()
|
temp = StringIO()
|
||||||
writer = csv.writer(temp)
|
writer = csv.writer(temp)
|
||||||
|
|
||||||
header = [column.name for column in model.__mapper__.columns]
|
header = [column.name for column in model.__mapper__.columns]
|
||||||
@@ -142,7 +142,7 @@ def export_csv():
|
|||||||
temp.seek(0)
|
temp.seek(0)
|
||||||
|
|
||||||
# In Python 3 send_file requires bytes
|
# In Python 3 send_file requires bytes
|
||||||
output = six.BytesIO()
|
output = BytesIO()
|
||||||
output.write(temp.getvalue().encode("utf-8"))
|
output.write(temp.getvalue().encode("utf-8"))
|
||||||
output.seek(0)
|
output.seek(0)
|
||||||
temp.close()
|
temp.close()
|
||||||
@@ -163,17 +163,13 @@ def config():
|
|||||||
# Clear the config cache so that we don't get stale values
|
# Clear the config cache so that we don't get stale values
|
||||||
clear_config()
|
clear_config()
|
||||||
|
|
||||||
database_tables = sorted(db.metadata.tables.keys())
|
|
||||||
|
|
||||||
configs = Configs.query.all()
|
configs = Configs.query.all()
|
||||||
configs = dict([(c.key, get_config(c.key)) for c in configs])
|
configs = dict([(c.key, get_config(c.key)) for c in configs])
|
||||||
|
|
||||||
themes = ctf_config.get_themes()
|
themes = ctf_config.get_themes()
|
||||||
themes.remove(get_config("ctf_theme"))
|
themes.remove(get_config("ctf_theme"))
|
||||||
|
|
||||||
return render_template(
|
return render_template("admin/config.html", themes=themes, **configs)
|
||||||
"admin/config.html", database_tables=database_tables, themes=themes, **configs
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.route("/admin/reset", methods=["GET", "POST"])
|
@admin.route("/admin/reset", methods=["GET", "POST"])
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import os
|
from flask import render_template, request, url_for
|
||||||
|
|
||||||
import six
|
|
||||||
from flask import current_app as app
|
|
||||||
from flask import render_template, render_template_string, request, url_for
|
|
||||||
|
|
||||||
from CTFd.admin import admin
|
from CTFd.admin import admin
|
||||||
from CTFd.models import Challenges, Flags, Solves
|
from CTFd.models import Challenges, Flags, Solves
|
||||||
from CTFd.plugins.challenges import get_chal_class
|
from CTFd.plugins.challenges import get_chal_class
|
||||||
from CTFd.utils import binary_type
|
|
||||||
from CTFd.utils.decorators import admins_only
|
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()
|
flags = Flags.query.filter_by(challenge_id=challenge.id).all()
|
||||||
challenge_class = get_chal_class(challenge.type)
|
challenge_class = get_chal_class(challenge.type)
|
||||||
|
|
||||||
with open(
|
update_j2 = render_template(
|
||||||
os.path.join(app.root_path, challenge_class.templates["update"].lstrip("/")),
|
challenge_class.templates["update"].lstrip("/"), challenge=challenge
|
||||||
"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_script = url_for(
|
update_script = url_for(
|
||||||
"views.static_html", route=challenge_class.scripts["update"].lstrip("/")
|
"views.static_html", route=challenge_class.scripts["update"].lstrip("/")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from CTFd.admin import admin
|
|||||||
from CTFd.models import Pages
|
from CTFd.models import Pages
|
||||||
from CTFd.schemas.pages import PageSchema
|
from CTFd.schemas.pages import PageSchema
|
||||||
from CTFd.utils import markdown
|
from CTFd.utils import markdown
|
||||||
|
from CTFd.utils.config.pages import build_html
|
||||||
from CTFd.utils.decorators import admins_only
|
from CTFd.utils.decorators import admins_only
|
||||||
|
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ def pages_preview():
|
|||||||
data = request.form.to_dict()
|
data = request.form.to_dict()
|
||||||
schema = PageSchema()
|
schema = PageSchema()
|
||||||
page = schema.load(data)
|
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>")
|
@admin.route("/admin/pages/<int:page_id>")
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ from CTFd.api.v1.flags import flags_namespace
|
|||||||
from CTFd.api.v1.hints import hints_namespace
|
from CTFd.api.v1.hints import hints_namespace
|
||||||
from CTFd.api.v1.notifications import notifications_namespace
|
from CTFd.api.v1.notifications import notifications_namespace
|
||||||
from CTFd.api.v1.pages import pages_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.scoreboard import scoreboard_namespace
|
||||||
from CTFd.api.v1.statistics import statistics_namespace
|
from CTFd.api.v1.statistics import statistics_namespace
|
||||||
from CTFd.api.v1.submissions import submissions_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
|
from CTFd.api.v1.users import users_namespace
|
||||||
|
|
||||||
api = Blueprint("api", __name__, url_prefix="/api/v1")
|
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(challenges_namespace, "/challenges")
|
||||||
CTFd_API_v1.add_namespace(tags_namespace, "/tags")
|
CTFd_API_v1.add_namespace(tags_namespace, "/tags")
|
||||||
|
|||||||
@@ -1,18 +1,103 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Namespace, Resource
|
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.cache import clear_standings
|
||||||
from CTFd.utils.config import is_teams_mode
|
from CTFd.constants import RawEnum
|
||||||
from CTFd.models import Awards, db, Users
|
from CTFd.models import Awards, Users, db
|
||||||
from CTFd.schemas.awards import AwardSchema
|
from CTFd.schemas.awards import AwardSchema
|
||||||
|
from CTFd.utils.config import is_teams_mode
|
||||||
from CTFd.utils.decorators import admins_only
|
from CTFd.utils.decorators import admins_only
|
||||||
|
|
||||||
awards_namespace = Namespace("awards", description="Endpoint to retrieve Awards")
|
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("")
|
@awards_namespace.route("")
|
||||||
class AwardList(Resource):
|
class AwardList(Resource):
|
||||||
@admins_only
|
@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):
|
def post(self):
|
||||||
req = request.get_json()
|
req = request.get_json()
|
||||||
|
|
||||||
@@ -57,6 +142,16 @@ class AwardList(Resource):
|
|||||||
@awards_namespace.param("award_id", "An Award ID")
|
@awards_namespace.param("award_id", "An Award ID")
|
||||||
class Award(Resource):
|
class Award(Resource):
|
||||||
@admins_only
|
@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):
|
def get(self, award_id):
|
||||||
award = Awards.query.filter_by(id=award_id).first_or_404()
|
award = Awards.query.filter_by(id=award_id).first_or_404()
|
||||||
response = AwardSchema().dump(award)
|
response = AwardSchema().dump(award)
|
||||||
@@ -66,6 +161,10 @@ class Award(Resource):
|
|||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@admins_only
|
@admins_only
|
||||||
|
@awards_namespace.doc(
|
||||||
|
description="Endpoint to delete an Award object",
|
||||||
|
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||||
|
)
|
||||||
def delete(self, award_id):
|
def delete(self, award_id):
|
||||||
award = Awards.query.filter_by(id=award_id).first_or_404()
|
award = Awards.query.filter_by(id=award_id).first_or_404()
|
||||||
db.session.delete(award)
|
db.session.delete(award)
|
||||||
|
|||||||
@@ -1,12 +1,28 @@
|
|||||||
import datetime
|
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 flask_restx import Namespace, Resource
|
||||||
from sqlalchemy.sql import and_
|
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.cache import clear_standings
|
||||||
|
from CTFd.constants import RawEnum
|
||||||
from CTFd.models import ChallengeFiles as ChallengeFilesModel
|
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.plugins.challenges import CHALLENGE_CLASSES, get_chal_class
|
||||||
from CTFd.schemas.flags import FlagSchema
|
from CTFd.schemas.flags import FlagSchema
|
||||||
from CTFd.schemas.hints import HintSchema
|
from CTFd.schemas.hints import HintSchema
|
||||||
@@ -37,25 +53,92 @@ challenges_namespace = Namespace(
|
|||||||
"challenges", description="Endpoint to retrieve Challenges"
|
"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("")
|
@challenges_namespace.route("")
|
||||||
class ChallengeList(Resource):
|
class ChallengeList(Resource):
|
||||||
@check_challenge_visibility
|
@check_challenge_visibility
|
||||||
@during_ctf_time_only
|
@during_ctf_time_only
|
||||||
@require_verified_emails
|
@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
|
# This can return None (unauth) if visibility is set to public
|
||||||
user = get_current_user()
|
user = get_current_user()
|
||||||
|
|
||||||
# Admins can request to see everything
|
# Admins can request to see everything
|
||||||
if is_admin() and request.args.get("view") == "admin":
|
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])
|
solve_ids = set([challenge.id for challenge in challenges])
|
||||||
else:
|
else:
|
||||||
challenges = (
|
challenges = (
|
||||||
Challenges.query.filter(
|
Challenges.query.filter(
|
||||||
and_(Challenges.state != "hidden", Challenges.state != "locked")
|
and_(Challenges.state != "hidden", Challenges.state != "locked")
|
||||||
)
|
)
|
||||||
|
.filter_by(**query_args)
|
||||||
|
.filter(*filters)
|
||||||
.order_by(Challenges.value)
|
.order_by(Challenges.value)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
@@ -122,6 +205,16 @@ class ChallengeList(Resource):
|
|||||||
return {"success": True, "data": response}
|
return {"success": True, "data": response}
|
||||||
|
|
||||||
@admins_only
|
@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):
|
def post(self):
|
||||||
data = request.form or request.get_json()
|
data = request.form or request.get_json()
|
||||||
challenge_type = data["type"]
|
challenge_type = data["type"]
|
||||||
@@ -144,16 +237,28 @@ class ChallengeTypes(Resource):
|
|||||||
"name": challenge_class.name,
|
"name": challenge_class.name,
|
||||||
"templates": challenge_class.templates,
|
"templates": challenge_class.templates,
|
||||||
"scripts": challenge_class.scripts,
|
"scripts": challenge_class.scripts,
|
||||||
|
"create": render_template(
|
||||||
|
challenge_class.templates["create"].lstrip("/")
|
||||||
|
),
|
||||||
}
|
}
|
||||||
return {"success": True, "data": response}
|
return {"success": True, "data": response}
|
||||||
|
|
||||||
|
|
||||||
@challenges_namespace.route("/<challenge_id>")
|
@challenges_namespace.route("/<challenge_id>")
|
||||||
@challenges_namespace.param("challenge_id", "A Challenge ID")
|
|
||||||
class Challenge(Resource):
|
class Challenge(Resource):
|
||||||
@check_challenge_visibility
|
@check_challenge_visibility
|
||||||
@during_ctf_time_only
|
@during_ctf_time_only
|
||||||
@require_verified_emails
|
@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):
|
def get(self, challenge_id):
|
||||||
if is_admin():
|
if is_admin():
|
||||||
chal = Challenges.query.filter(Challenges.id == challenge_id).first_or_404()
|
chal = Challenges.query.filter(Challenges.id == challenge_id).first_or_404()
|
||||||
@@ -270,14 +375,44 @@ class Challenge(Resource):
|
|||||||
else:
|
else:
|
||||||
response["solves"] = None
|
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["files"] = files
|
||||||
response["tags"] = tags
|
response["tags"] = tags
|
||||||
response["hints"] = hints
|
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()
|
db.session.close()
|
||||||
return {"success": True, "data": response}
|
return {"success": True, "data": response}
|
||||||
|
|
||||||
@admins_only
|
@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):
|
def patch(self, challenge_id):
|
||||||
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
|
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
|
||||||
challenge_class = get_chal_class(challenge.type)
|
challenge_class = get_chal_class(challenge.type)
|
||||||
@@ -286,6 +421,10 @@ class Challenge(Resource):
|
|||||||
return {"success": True, "data": response}
|
return {"success": True, "data": response}
|
||||||
|
|
||||||
@admins_only
|
@admins_only
|
||||||
|
@challenges_namespace.doc(
|
||||||
|
description="Endpoint to delete a specific Challenge object",
|
||||||
|
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||||
|
)
|
||||||
def delete(self, challenge_id):
|
def delete(self, challenge_id):
|
||||||
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
|
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
|
||||||
chal_class = get_chal_class(challenge.type)
|
chal_class = get_chal_class(challenge.type)
|
||||||
@@ -496,7 +635,6 @@ class ChallengeAttempt(Resource):
|
|||||||
|
|
||||||
|
|
||||||
@challenges_namespace.route("/<challenge_id>/solves")
|
@challenges_namespace.route("/<challenge_id>/solves")
|
||||||
@challenges_namespace.param("id", "A Challenge ID")
|
|
||||||
class ChallengeSolves(Resource):
|
class ChallengeSolves(Resource):
|
||||||
@check_challenge_visibility
|
@check_challenge_visibility
|
||||||
@check_score_visibility
|
@check_score_visibility
|
||||||
@@ -544,7 +682,6 @@ class ChallengeSolves(Resource):
|
|||||||
|
|
||||||
|
|
||||||
@challenges_namespace.route("/<challenge_id>/files")
|
@challenges_namespace.route("/<challenge_id>/files")
|
||||||
@challenges_namespace.param("id", "A Challenge ID")
|
|
||||||
class ChallengeFiles(Resource):
|
class ChallengeFiles(Resource):
|
||||||
@admins_only
|
@admins_only
|
||||||
def get(self, challenge_id):
|
def get(self, challenge_id):
|
||||||
@@ -560,7 +697,6 @@ class ChallengeFiles(Resource):
|
|||||||
|
|
||||||
|
|
||||||
@challenges_namespace.route("/<challenge_id>/tags")
|
@challenges_namespace.route("/<challenge_id>/tags")
|
||||||
@challenges_namespace.param("id", "A Challenge ID")
|
|
||||||
class ChallengeTags(Resource):
|
class ChallengeTags(Resource):
|
||||||
@admins_only
|
@admins_only
|
||||||
def get(self, challenge_id):
|
def get(self, challenge_id):
|
||||||
@@ -576,7 +712,6 @@ class ChallengeTags(Resource):
|
|||||||
|
|
||||||
|
|
||||||
@challenges_namespace.route("/<challenge_id>/hints")
|
@challenges_namespace.route("/<challenge_id>/hints")
|
||||||
@challenges_namespace.param("id", "A Challenge ID")
|
|
||||||
class ChallengeHints(Resource):
|
class ChallengeHints(Resource):
|
||||||
@admins_only
|
@admins_only
|
||||||
def get(self, challenge_id):
|
def get(self, challenge_id):
|
||||||
@@ -591,7 +726,6 @@ class ChallengeHints(Resource):
|
|||||||
|
|
||||||
|
|
||||||
@challenges_namespace.route("/<challenge_id>/flags")
|
@challenges_namespace.route("/<challenge_id>/flags")
|
||||||
@challenges_namespace.param("id", "A Challenge ID")
|
|
||||||
class ChallengeFlags(Resource):
|
class ChallengeFlags(Resource):
|
||||||
@admins_only
|
@admins_only
|
||||||
def get(self, challenge_id):
|
def get(self, challenge_id):
|
||||||
|
|||||||
@@ -1,20 +1,69 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Namespace, Resource
|
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.cache import clear_config, clear_standings
|
||||||
|
from CTFd.constants import RawEnum
|
||||||
from CTFd.models import Configs, db
|
from CTFd.models import Configs, db
|
||||||
from CTFd.schemas.config import ConfigSchema
|
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
|
from CTFd.utils.decorators import admins_only
|
||||||
|
|
||||||
configs_namespace = Namespace("configs", description="Endpoint to retrieve Configs")
|
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("")
|
@configs_namespace.route("")
|
||||||
class ConfigList(Resource):
|
class ConfigList(Resource):
|
||||||
@admins_only
|
@admins_only
|
||||||
def get(self):
|
@configs_namespace.doc(
|
||||||
configs = Configs.query.all()
|
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)
|
schema = ConfigSchema(many=True)
|
||||||
response = schema.dump(configs)
|
response = schema.dump(configs)
|
||||||
if response.errors:
|
if response.errors:
|
||||||
@@ -23,6 +72,16 @@ class ConfigList(Resource):
|
|||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@admins_only
|
@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):
|
def post(self):
|
||||||
req = request.get_json()
|
req = request.get_json()
|
||||||
schema = ConfigSchema()
|
schema = ConfigSchema()
|
||||||
@@ -43,6 +102,10 @@ class ConfigList(Resource):
|
|||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@admins_only
|
@admins_only
|
||||||
|
@configs_namespace.doc(
|
||||||
|
description="Endpoint to get patch Config objects in bulk",
|
||||||
|
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||||
|
)
|
||||||
def patch(self):
|
def patch(self):
|
||||||
req = request.get_json()
|
req = request.get_json()
|
||||||
|
|
||||||
@@ -58,11 +121,33 @@ class ConfigList(Resource):
|
|||||||
@configs_namespace.route("/<config_key>")
|
@configs_namespace.route("/<config_key>")
|
||||||
class Config(Resource):
|
class Config(Resource):
|
||||||
@admins_only
|
@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):
|
def get(self, config_key):
|
||||||
|
config = Configs.query.filter_by(key=config_key).first_or_404()
|
||||||
return {"success": True, "data": get_config(config_key)}
|
schema = ConfigSchema()
|
||||||
|
response = schema.dump(config)
|
||||||
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@admins_only
|
@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):
|
def patch(self, config_key):
|
||||||
config = Configs.query.filter_by(key=config_key).first()
|
config = Configs.query.filter_by(key=config_key).first()
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
@@ -89,6 +174,10 @@ class Config(Resource):
|
|||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@admins_only
|
@admins_only
|
||||||
|
@configs_namespace.doc(
|
||||||
|
description="Endpoint to delete a Config object",
|
||||||
|
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||||
|
)
|
||||||
def delete(self, config_key):
|
def delete(self, config_key):
|
||||||
config = Configs.query.filter_by(key=config_key).first_or_404()
|
config = Configs.query.filter_by(key=config_key).first_or_404()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Namespace, Resource
|
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.models import Files, db
|
||||||
from CTFd.schemas.files import FileSchema
|
from CTFd.schemas.files import FileSchema
|
||||||
from CTFd.utils import uploads
|
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")
|
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("")
|
@files_namespace.route("")
|
||||||
class FilesList(Resource):
|
class FilesList(Resource):
|
||||||
@admins_only
|
@admins_only
|
||||||
def get(self):
|
@files_namespace.doc(
|
||||||
file_type = request.args.get("type")
|
description="Endpoint to get file objects in bulk",
|
||||||
files = Files.query.filter_by(type=file_type).all()
|
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)
|
schema = FileSchema(many=True)
|
||||||
response = schema.dump(files)
|
response = schema.dump(files)
|
||||||
|
|
||||||
@@ -24,6 +75,16 @@ class FilesList(Resource):
|
|||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@admins_only
|
@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):
|
def post(self):
|
||||||
files = request.files.getlist("file")
|
files = request.files.getlist("file")
|
||||||
# challenge_id
|
# challenge_id
|
||||||
@@ -47,6 +108,16 @@ class FilesList(Resource):
|
|||||||
@files_namespace.route("/<file_id>")
|
@files_namespace.route("/<file_id>")
|
||||||
class FilesDetail(Resource):
|
class FilesDetail(Resource):
|
||||||
@admins_only
|
@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):
|
def get(self, file_id):
|
||||||
f = Files.query.filter_by(id=file_id).first_or_404()
|
f = Files.query.filter_by(id=file_id).first_or_404()
|
||||||
schema = FileSchema()
|
schema = FileSchema()
|
||||||
@@ -58,6 +129,10 @@ class FilesDetail(Resource):
|
|||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@admins_only
|
@admins_only
|
||||||
|
@files_namespace.doc(
|
||||||
|
description="Endpoint to delete a file object",
|
||||||
|
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||||
|
)
|
||||||
def delete(self, file_id):
|
def delete(self, file_id):
|
||||||
f = Files.query.filter_by(id=file_id).first_or_404()
|
f = Files.query.filter_by(id=file_id).first_or_404()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Namespace, Resource
|
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.models import Flags, db
|
||||||
from CTFd.plugins.flags import FLAG_CLASSES, get_flag_class
|
from CTFd.plugins.flags import FLAG_CLASSES, get_flag_class
|
||||||
from CTFd.schemas.flags import FlagSchema
|
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")
|
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("")
|
@flags_namespace.route("")
|
||||||
class FlagList(Resource):
|
class FlagList(Resource):
|
||||||
@admins_only
|
@admins_only
|
||||||
def get(self):
|
@flags_namespace.doc(
|
||||||
flags = Flags.query.all()
|
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)
|
schema = FlagSchema(many=True)
|
||||||
response = schema.dump(flags)
|
response = schema.dump(flags)
|
||||||
if response.errors:
|
if response.errors:
|
||||||
@@ -22,6 +78,16 @@ class FlagList(Resource):
|
|||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@admins_only
|
@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):
|
def post(self):
|
||||||
req = request.get_json()
|
req = request.get_json()
|
||||||
schema = FlagSchema()
|
schema = FlagSchema()
|
||||||
@@ -62,6 +128,16 @@ class FlagTypes(Resource):
|
|||||||
@flags_namespace.route("/<flag_id>")
|
@flags_namespace.route("/<flag_id>")
|
||||||
class Flag(Resource):
|
class Flag(Resource):
|
||||||
@admins_only
|
@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):
|
def get(self, flag_id):
|
||||||
flag = Flags.query.filter_by(id=flag_id).first_or_404()
|
flag = Flags.query.filter_by(id=flag_id).first_or_404()
|
||||||
schema = FlagSchema()
|
schema = FlagSchema()
|
||||||
@@ -75,6 +151,10 @@ class Flag(Resource):
|
|||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@admins_only
|
@admins_only
|
||||||
|
@flags_namespace.doc(
|
||||||
|
description="Endpoint to delete a specific Flag object",
|
||||||
|
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||||
|
)
|
||||||
def delete(self, flag_id):
|
def delete(self, flag_id):
|
||||||
flag = Flags.query.filter_by(id=flag_id).first_or_404()
|
flag = Flags.query.filter_by(id=flag_id).first_or_404()
|
||||||
|
|
||||||
@@ -85,6 +165,16 @@ class Flag(Resource):
|
|||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
@admins_only
|
@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):
|
def patch(self, flag_id):
|
||||||
flag = Flags.query.filter_by(id=flag_id).first_or_404()
|
flag = Flags.query.filter_by(id=flag_id).first_or_404()
|
||||||
schema = FlagSchema()
|
schema = FlagSchema()
|
||||||
|
|||||||
0
CTFd/api/v1/helpers/__init__.py
Normal file
0
CTFd/api/v1/helpers/__init__.py
Normal file
7
CTFd/api/v1/helpers/models.py
Normal file
7
CTFd/api/v1/helpers/models.py
Normal 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
|
||||||
49
CTFd/api/v1/helpers/request.py
Normal file
49
CTFd/api/v1/helpers/request.py
Normal 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
|
||||||
31
CTFd/api/v1/helpers/schemas.py
Normal file
31
CTFd/api/v1/helpers/schemas.py
Normal 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
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Namespace, Resource
|
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.models import Hints, HintUnlocks, db
|
||||||
from CTFd.schemas.hints import HintSchema
|
from CTFd.schemas.hints import HintSchema
|
||||||
from CTFd.utils.decorators import admins_only, authed_only, during_ctf_time_only
|
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")
|
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("")
|
@hints_namespace.route("")
|
||||||
class HintList(Resource):
|
class HintList(Resource):
|
||||||
@admins_only
|
@admins_only
|
||||||
def get(self):
|
@hints_namespace.doc(
|
||||||
hints = Hints.query.all()
|
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)
|
response = HintSchema(many=True).dump(hints)
|
||||||
|
|
||||||
if response.errors:
|
if response.errors:
|
||||||
@@ -22,6 +76,16 @@ class HintList(Resource):
|
|||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@admins_only
|
@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):
|
def post(self):
|
||||||
req = request.get_json()
|
req = request.get_json()
|
||||||
schema = HintSchema("admin")
|
schema = HintSchema("admin")
|
||||||
@@ -42,6 +106,16 @@ class HintList(Resource):
|
|||||||
class Hint(Resource):
|
class Hint(Resource):
|
||||||
@during_ctf_time_only
|
@during_ctf_time_only
|
||||||
@authed_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):
|
def get(self, hint_id):
|
||||||
user = get_current_user()
|
user = get_current_user()
|
||||||
hint = Hints.query.filter_by(id=hint_id).first_or_404()
|
hint = Hints.query.filter_by(id=hint_id).first_or_404()
|
||||||
@@ -67,6 +141,16 @@ class Hint(Resource):
|
|||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@admins_only
|
@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):
|
def patch(self, hint_id):
|
||||||
hint = Hints.query.filter_by(id=hint_id).first_or_404()
|
hint = Hints.query.filter_by(id=hint_id).first_or_404()
|
||||||
req = request.get_json()
|
req = request.get_json()
|
||||||
@@ -85,6 +169,10 @@ class Hint(Resource):
|
|||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@admins_only
|
@admins_only
|
||||||
|
@hints_namespace.doc(
|
||||||
|
description="Endpoint to delete a specific Tag object",
|
||||||
|
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||||
|
)
|
||||||
def delete(self, hint_id):
|
def delete(self, hint_id):
|
||||||
hint = Hints.query.filter_by(id=hint_id).first_or_404()
|
hint = Hints.query.filter_by(id=hint_id).first_or_404()
|
||||||
db.session.delete(hint)
|
db.session.delete(hint)
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
from flask import current_app, request
|
from flask import current_app, request
|
||||||
from flask_restx import Namespace, Resource
|
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.models import Notifications, db
|
||||||
from CTFd.schemas.notifications import NotificationSchema
|
from CTFd.schemas.notifications import NotificationSchema
|
||||||
from CTFd.utils.decorators import admins_only
|
from CTFd.utils.decorators import admins_only
|
||||||
@@ -9,11 +16,61 @@ notifications_namespace = Namespace(
|
|||||||
"notifications", description="Endpoint to retrieve Notifications"
|
"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("")
|
@notifications_namespace.route("")
|
||||||
class NotificantionList(Resource):
|
class NotificantionList(Resource):
|
||||||
def get(self):
|
@notifications_namespace.doc(
|
||||||
notifications = Notifications.query.all()
|
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)
|
schema = NotificationSchema(many=True)
|
||||||
result = schema.dump(notifications)
|
result = schema.dump(notifications)
|
||||||
if result.errors:
|
if result.errors:
|
||||||
@@ -21,6 +78,16 @@ class NotificantionList(Resource):
|
|||||||
return {"success": True, "data": result.data}
|
return {"success": True, "data": result.data}
|
||||||
|
|
||||||
@admins_only
|
@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):
|
def post(self):
|
||||||
req = request.get_json()
|
req = request.get_json()
|
||||||
|
|
||||||
@@ -49,6 +116,16 @@ class NotificantionList(Resource):
|
|||||||
@notifications_namespace.route("/<notification_id>")
|
@notifications_namespace.route("/<notification_id>")
|
||||||
@notifications_namespace.param("notification_id", "A Notification ID")
|
@notifications_namespace.param("notification_id", "A Notification ID")
|
||||||
class Notification(Resource):
|
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):
|
def get(self, notification_id):
|
||||||
notif = Notifications.query.filter_by(id=notification_id).first_or_404()
|
notif = Notifications.query.filter_by(id=notification_id).first_or_404()
|
||||||
schema = NotificationSchema()
|
schema = NotificationSchema()
|
||||||
@@ -59,6 +136,10 @@ class Notification(Resource):
|
|||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@admins_only
|
@admins_only
|
||||||
|
@notifications_namespace.doc(
|
||||||
|
description="Endpoint to delete a notification object",
|
||||||
|
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||||
|
)
|
||||||
def delete(self, notification_id):
|
def delete(self, notification_id):
|
||||||
notif = Notifications.query.filter_by(id=notification_id).first_or_404()
|
notif = Notifications.query.filter_by(id=notification_id).first_or_404()
|
||||||
db.session.delete(notif)
|
db.session.delete(notif)
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Namespace, Resource
|
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.cache import clear_pages
|
||||||
|
from CTFd.constants import RawEnum
|
||||||
from CTFd.models import Pages, db
|
from CTFd.models import Pages, db
|
||||||
from CTFd.schemas.pages import PageSchema
|
from CTFd.schemas.pages import PageSchema
|
||||||
from CTFd.utils.decorators import admins_only
|
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")
|
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.route("")
|
||||||
|
@pages_namespace.doc(
|
||||||
|
responses={200: "Success", 400: "An error occured processing your data"}
|
||||||
|
)
|
||||||
class PageList(Resource):
|
class PageList(Resource):
|
||||||
@admins_only
|
@admins_only
|
||||||
def get(self):
|
@pages_namespace.doc(
|
||||||
pages = Pages.query.all()
|
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)
|
schema = PageSchema(exclude=["content"], many=True)
|
||||||
response = schema.dump(pages)
|
response = schema.dump(pages)
|
||||||
if response.errors:
|
if response.errors:
|
||||||
@@ -22,8 +86,19 @@ class PageList(Resource):
|
|||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@admins_only
|
@admins_only
|
||||||
def post(self):
|
@pages_namespace.doc(
|
||||||
req = request.get_json()
|
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()
|
schema = PageSchema()
|
||||||
response = schema.load(req)
|
response = schema.load(req)
|
||||||
|
|
||||||
@@ -42,8 +117,19 @@ class PageList(Resource):
|
|||||||
|
|
||||||
|
|
||||||
@pages_namespace.route("/<page_id>")
|
@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):
|
class PageDetail(Resource):
|
||||||
@admins_only
|
@admins_only
|
||||||
|
@pages_namespace.doc(description="Endpoint to read a page object")
|
||||||
def get(self, page_id):
|
def get(self, page_id):
|
||||||
page = Pages.query.filter_by(id=page_id).first_or_404()
|
page = Pages.query.filter_by(id=page_id).first_or_404()
|
||||||
schema = PageSchema()
|
schema = PageSchema()
|
||||||
@@ -55,6 +141,7 @@ class PageDetail(Resource):
|
|||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@admins_only
|
@admins_only
|
||||||
|
@pages_namespace.doc(description="Endpoint to edit a page object")
|
||||||
def patch(self, page_id):
|
def patch(self, page_id):
|
||||||
page = Pages.query.filter_by(id=page_id).first_or_404()
|
page = Pages.query.filter_by(id=page_id).first_or_404()
|
||||||
req = request.get_json()
|
req = request.get_json()
|
||||||
@@ -75,6 +162,10 @@ class PageDetail(Resource):
|
|||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@admins_only
|
@admins_only
|
||||||
|
@pages_namespace.doc(
|
||||||
|
description="Endpoint to delete a page object",
|
||||||
|
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||||
|
)
|
||||||
def delete(self, page_id):
|
def delete(self, page_id):
|
||||||
page = Pages.query.filter_by(id=page_id).first_or_404()
|
page = Pages.query.filter_by(id=page_id).first_or_404()
|
||||||
db.session.delete(page)
|
db.session.delete(page)
|
||||||
|
|||||||
105
CTFd/api/v1/schemas/__init__.py
Normal file
105
CTFd/api/v1/schemas/__init__.py
Normal 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]]
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
from flask_restx import Namespace, Resource
|
from flask_restx import Namespace, Resource
|
||||||
|
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from CTFd.cache import cache, make_cache_key
|
from CTFd.cache import cache, make_cache_key
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ statistics_namespace = Namespace(
|
|||||||
"statistics", description="Endpoint to retrieve Statistics"
|
"statistics", description="Endpoint to retrieve Statistics"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# isort:imports-firstparty
|
||||||
from CTFd.api.v1.statistics import challenges # noqa: F401
|
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 submissions # noqa: F401
|
||||||
from CTFd.api.v1.statistics import teams # 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 users # noqa: F401
|
||||||
from CTFd.api.v1.statistics import scores # noqa: F401
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from collections import defaultdict
|
|||||||
from flask_restx import Resource
|
from flask_restx import Resource
|
||||||
|
|
||||||
from CTFd.api.v1.statistics import statistics_namespace
|
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.decorators import admins_only
|
||||||
from CTFd.utils.scores import get_standings
|
from CTFd.utils.scores import get_standings
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
from flask import request
|
from typing import List
|
||||||
|
|
||||||
from flask_restx import Namespace, Resource
|
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.cache import clear_standings
|
||||||
|
from CTFd.constants import RawEnum
|
||||||
from CTFd.models import Submissions, db
|
from CTFd.models import Submissions, db
|
||||||
from CTFd.schemas.submissions import SubmissionSchema
|
from CTFd.schemas.submissions import SubmissionSchema
|
||||||
from CTFd.utils.decorators import admins_only
|
from CTFd.utils.decorators import admins_only
|
||||||
@@ -10,28 +19,114 @@ submissions_namespace = Namespace(
|
|||||||
"submissions", description="Endpoint to retrieve Submission"
|
"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("")
|
@submissions_namespace.route("")
|
||||||
class SubmissionsList(Resource):
|
class SubmissionsList(Resource):
|
||||||
@admins_only
|
@admins_only
|
||||||
def get(self):
|
@submissions_namespace.doc(
|
||||||
args = request.args.to_dict()
|
description="Endpoint to get submission objects in bulk",
|
||||||
schema = SubmissionSchema(many=True)
|
responses={
|
||||||
if args:
|
200: ("Success", "SubmissionListSuccessResponse"),
|
||||||
submissions = Submissions.query.filter_by(**args).all()
|
400: (
|
||||||
else:
|
"An error occured processing the provided or stored data",
|
||||||
submissions = Submissions.query.all()
|
"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:
|
if response.errors:
|
||||||
return {"success": False, "errors": response.errors}, 400
|
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
|
@admins_only
|
||||||
def post(self):
|
@submissions_namespace.doc(
|
||||||
req = request.get_json()
|
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"))
|
Model = Submissions.get_child(type=req.get("type"))
|
||||||
schema = SubmissionSchema(instance=Model())
|
schema = SubmissionSchema(instance=Model())
|
||||||
response = schema.load(req)
|
response = schema.load(req)
|
||||||
@@ -54,6 +149,16 @@ class SubmissionsList(Resource):
|
|||||||
@submissions_namespace.param("submission_id", "A Submission ID")
|
@submissions_namespace.param("submission_id", "A Submission ID")
|
||||||
class Submission(Resource):
|
class Submission(Resource):
|
||||||
@admins_only
|
@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):
|
def get(self, submission_id):
|
||||||
submission = Submissions.query.filter_by(id=submission_id).first_or_404()
|
submission = Submissions.query.filter_by(id=submission_id).first_or_404()
|
||||||
schema = SubmissionSchema()
|
schema = SubmissionSchema()
|
||||||
@@ -65,6 +170,16 @@ class Submission(Resource):
|
|||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@admins_only
|
@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):
|
def delete(self, submission_id):
|
||||||
submission = Submissions.query.filter_by(id=submission_id).first_or_404()
|
submission = Submissions.query.filter_by(id=submission_id).first_or_404()
|
||||||
db.session.delete(submission)
|
db.session.delete(submission)
|
||||||
|
|||||||
@@ -1,19 +1,70 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Namespace, Resource
|
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.models import Tags, db
|
||||||
from CTFd.schemas.tags import TagSchema
|
from CTFd.schemas.tags import TagSchema
|
||||||
from CTFd.utils.decorators import admins_only
|
from CTFd.utils.decorators import admins_only
|
||||||
|
|
||||||
tags_namespace = Namespace("tags", description="Endpoint to retrieve Tags")
|
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("")
|
@tags_namespace.route("")
|
||||||
class TagList(Resource):
|
class TagList(Resource):
|
||||||
@admins_only
|
@admins_only
|
||||||
def get(self):
|
@tags_namespace.doc(
|
||||||
# TODO: Filter by challenge_id
|
description="Endpoint to list Tag objects in bulk",
|
||||||
tags = Tags.query.all()
|
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)
|
schema = TagSchema(many=True)
|
||||||
response = schema.dump(tags)
|
response = schema.dump(tags)
|
||||||
|
|
||||||
@@ -23,6 +74,16 @@ class TagList(Resource):
|
|||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@admins_only
|
@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):
|
def post(self):
|
||||||
req = request.get_json()
|
req = request.get_json()
|
||||||
schema = TagSchema()
|
schema = TagSchema()
|
||||||
@@ -44,6 +105,16 @@ class TagList(Resource):
|
|||||||
@tags_namespace.param("tag_id", "A Tag ID")
|
@tags_namespace.param("tag_id", "A Tag ID")
|
||||||
class Tag(Resource):
|
class Tag(Resource):
|
||||||
@admins_only
|
@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):
|
def get(self, tag_id):
|
||||||
tag = Tags.query.filter_by(id=tag_id).first_or_404()
|
tag = Tags.query.filter_by(id=tag_id).first_or_404()
|
||||||
|
|
||||||
@@ -55,6 +126,16 @@ class Tag(Resource):
|
|||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@admins_only
|
@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):
|
def patch(self, tag_id):
|
||||||
tag = Tags.query.filter_by(id=tag_id).first_or_404()
|
tag = Tags.query.filter_by(id=tag_id).first_or_404()
|
||||||
schema = TagSchema()
|
schema = TagSchema()
|
||||||
@@ -72,6 +153,10 @@ class Tag(Resource):
|
|||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@admins_only
|
@admins_only
|
||||||
|
@tags_namespace.doc(
|
||||||
|
description="Endpoint to delete a specific Tag object",
|
||||||
|
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||||
|
)
|
||||||
def delete(self, tag_id):
|
def delete(self, tag_id):
|
||||||
tag = Tags.query.filter_by(id=tag_id).first_or_404()
|
tag = Tags.query.filter_by(id=tag_id).first_or_404()
|
||||||
db.session.delete(tag)
|
db.session.delete(tag)
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import copy
|
import copy
|
||||||
|
from typing import List
|
||||||
|
|
||||||
from flask import abort, request, session
|
from flask import abort, request, session
|
||||||
from flask_restx import Namespace, Resource
|
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.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.models import Awards, Submissions, Teams, Unlocks, Users, db
|
||||||
from CTFd.schemas.awards import AwardSchema
|
from CTFd.schemas.awards import AwardSchema
|
||||||
from CTFd.schemas.submissions import SubmissionSchema
|
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")
|
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("")
|
@teams_namespace.route("")
|
||||||
class TeamList(Resource):
|
class TeamList(Resource):
|
||||||
@check_account_visibility
|
@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":
|
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:
|
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")
|
user_type = get_current_user_type(fallback="user")
|
||||||
view = copy.deepcopy(TeamSchema.views.get(user_type))
|
view = copy.deepcopy(TeamSchema.views.get(user_type))
|
||||||
view.remove("members")
|
view.remove("members")
|
||||||
response = TeamSchema(view=view, many=True).dump(teams)
|
response = TeamSchema(view=view, many=True).dump(teams.items)
|
||||||
|
|
||||||
if response.errors:
|
if response.errors:
|
||||||
return {"success": False, "errors": response.errors}, 400
|
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
|
@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):
|
def post(self):
|
||||||
req = request.get_json()
|
req = request.get_json()
|
||||||
user_type = get_current_user_type()
|
user_type = get_current_user_type()
|
||||||
@@ -63,6 +159,16 @@ class TeamList(Resource):
|
|||||||
@teams_namespace.param("team_id", "Team ID")
|
@teams_namespace.param("team_id", "Team ID")
|
||||||
class TeamPublic(Resource):
|
class TeamPublic(Resource):
|
||||||
@check_account_visibility
|
@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):
|
def get(self, team_id):
|
||||||
team = Teams.query.filter_by(id=team_id).first_or_404()
|
team = Teams.query.filter_by(id=team_id).first_or_404()
|
||||||
|
|
||||||
@@ -82,6 +188,16 @@ class TeamPublic(Resource):
|
|||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@admins_only
|
@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):
|
def patch(self, team_id):
|
||||||
team = Teams.query.filter_by(id=team_id).first_or_404()
|
team = Teams.query.filter_by(id=team_id).first_or_404()
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
@@ -104,6 +220,10 @@ class TeamPublic(Resource):
|
|||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@admins_only
|
@admins_only
|
||||||
|
@teams_namespace.doc(
|
||||||
|
description="Endpoint to delete a specific Team object",
|
||||||
|
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||||
|
)
|
||||||
def delete(self, team_id):
|
def delete(self, team_id):
|
||||||
team = Teams.query.filter_by(id=team_id).first_or_404()
|
team = Teams.query.filter_by(id=team_id).first_or_404()
|
||||||
team_id = team.id
|
team_id = team.id
|
||||||
@@ -128,6 +248,16 @@ class TeamPublic(Resource):
|
|||||||
class TeamPrivate(Resource):
|
class TeamPrivate(Resource):
|
||||||
@authed_only
|
@authed_only
|
||||||
@require_team
|
@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):
|
def get(self):
|
||||||
team = get_current_team()
|
team = get_current_team()
|
||||||
response = TeamSchema(view="self").dump(team)
|
response = TeamSchema(view="self").dump(team)
|
||||||
@@ -141,6 +271,16 @@ class TeamPrivate(Resource):
|
|||||||
|
|
||||||
@authed_only
|
@authed_only
|
||||||
@require_team
|
@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):
|
def patch(self):
|
||||||
team = get_current_team()
|
team = get_current_team()
|
||||||
if team.captain_id != session["id"]:
|
if team.captain_id != session["id"]:
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
from typing import List
|
||||||
|
|
||||||
from flask import request, session
|
from flask import request, session
|
||||||
from flask_restx import Namespace, Resource
|
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.models import Tokens, db
|
||||||
from CTFd.schemas.tokens import TokenSchema
|
from CTFd.schemas.tokens import TokenSchema
|
||||||
from CTFd.utils.decorators import authed_only, require_verified_emails
|
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")
|
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("")
|
@tokens_namespace.route("")
|
||||||
class TokenList(Resource):
|
class TokenList(Resource):
|
||||||
@require_verified_emails
|
@require_verified_emails
|
||||||
@authed_only
|
@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):
|
def get(self):
|
||||||
user = get_current_user()
|
user = get_current_user()
|
||||||
tokens = Tokens.query.filter_by(user_id=user.id)
|
tokens = Tokens.query.filter_by(user_id=user.id)
|
||||||
@@ -30,6 +72,16 @@ class TokenList(Resource):
|
|||||||
|
|
||||||
@require_verified_emails
|
@require_verified_emails
|
||||||
@authed_only
|
@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):
|
def post(self):
|
||||||
req = request.get_json()
|
req = request.get_json()
|
||||||
expiration = req.get("expiration")
|
expiration = req.get("expiration")
|
||||||
@@ -54,6 +106,16 @@ class TokenList(Resource):
|
|||||||
class TokenDetail(Resource):
|
class TokenDetail(Resource):
|
||||||
@require_verified_emails
|
@require_verified_emails
|
||||||
@authed_only
|
@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):
|
def get(self, token_id):
|
||||||
if is_admin():
|
if is_admin():
|
||||||
token = Tokens.query.filter_by(id=token_id).first_or_404()
|
token = Tokens.query.filter_by(id=token_id).first_or_404()
|
||||||
@@ -73,6 +135,10 @@ class TokenDetail(Resource):
|
|||||||
|
|
||||||
@require_verified_emails
|
@require_verified_emails
|
||||||
@authed_only
|
@authed_only
|
||||||
|
@tokens_namespace.doc(
|
||||||
|
description="Endpoint to delete an existing token object",
|
||||||
|
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||||
|
)
|
||||||
def delete(self, token_id):
|
def delete(self, token_id):
|
||||||
if is_admin():
|
if is_admin():
|
||||||
token = Tokens.query.filter_by(id=token_id).first_or_404()
|
token = Tokens.query.filter_by(id=token_id).first_or_404()
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Namespace, Resource
|
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.cache import clear_standings
|
||||||
|
from CTFd.constants import RawEnum
|
||||||
from CTFd.models import Unlocks, db, get_class_by_tablename
|
from CTFd.models import Unlocks, db, get_class_by_tablename
|
||||||
from CTFd.schemas.awards import AwardSchema
|
from CTFd.schemas.awards import AwardSchema
|
||||||
from CTFd.schemas.unlocks import UnlockSchema
|
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")
|
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("")
|
@unlocks_namespace.route("")
|
||||||
class UnlockList(Resource):
|
class UnlockList(Resource):
|
||||||
@admins_only
|
@admins_only
|
||||||
def get(self):
|
@unlocks_namespace.doc(
|
||||||
hints = Unlocks.query.all()
|
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()
|
schema = UnlockSchema()
|
||||||
response = schema.dump(hints)
|
response = schema.dump(unlocks)
|
||||||
|
|
||||||
if response.errors:
|
if response.errors:
|
||||||
return {"success": False, "errors": response.errors}, 400
|
return {"success": False, "errors": response.errors}, 400
|
||||||
@@ -32,6 +87,16 @@ class UnlockList(Resource):
|
|||||||
@during_ctf_time_only
|
@during_ctf_time_only
|
||||||
@require_verified_emails
|
@require_verified_emails
|
||||||
@authed_only
|
@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):
|
def post(self):
|
||||||
req = request.get_json()
|
req = request.get_json()
|
||||||
user = get_current_user()
|
user = get_current_user()
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
from flask import abort, request
|
from flask import abort, request
|
||||||
from flask_restx import Namespace, Resource
|
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.cache import clear_standings, clear_user_session
|
||||||
|
from CTFd.constants import RawEnum
|
||||||
from CTFd.models import (
|
from CTFd.models import (
|
||||||
Awards,
|
Awards,
|
||||||
Notifications,
|
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")
|
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("")
|
@users_namespace.route("")
|
||||||
class UserList(Resource):
|
class UserList(Resource):
|
||||||
@check_account_visibility
|
@check_account_visibility
|
||||||
def get(self):
|
@users_namespace.doc(
|
||||||
if is_admin() and request.args.get("view") == "admin":
|
description="Endpoint to get User objects in bulk",
|
||||||
users = Users.query.filter_by()
|
responses={
|
||||||
else:
|
200: ("Success", "UserListSuccessResponse"),
|
||||||
users = Users.query.filter_by(banned=False, hidden=False)
|
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:
|
if response.errors:
|
||||||
return {"success": False, "errors": response.errors}, 400
|
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(
|
@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={
|
params={
|
||||||
"notify": "Whether to send the created user an email with their credentials"
|
"notify": "Whether to send the created user an email with their credentials"
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
@admins_only
|
|
||||||
def post(self):
|
def post(self):
|
||||||
req = request.get_json()
|
req = request.get_json()
|
||||||
schema = UserSchema("admin")
|
schema = UserSchema("admin")
|
||||||
@@ -79,6 +176,16 @@ class UserList(Resource):
|
|||||||
@users_namespace.param("user_id", "User ID")
|
@users_namespace.param("user_id", "User ID")
|
||||||
class UserPublic(Resource):
|
class UserPublic(Resource):
|
||||||
@check_account_visibility
|
@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):
|
def get(self, user_id):
|
||||||
user = Users.query.filter_by(id=user_id).first_or_404()
|
user = Users.query.filter_by(id=user_id).first_or_404()
|
||||||
|
|
||||||
@@ -97,6 +204,16 @@ class UserPublic(Resource):
|
|||||||
return {"success": True, "data": response.data}
|
return {"success": True, "data": response.data}
|
||||||
|
|
||||||
@admins_only
|
@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):
|
def patch(self, user_id):
|
||||||
user = Users.query.filter_by(id=user_id).first_or_404()
|
user = Users.query.filter_by(id=user_id).first_or_404()
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
@@ -118,6 +235,10 @@ class UserPublic(Resource):
|
|||||||
return {"success": True, "data": response}
|
return {"success": True, "data": response}
|
||||||
|
|
||||||
@admins_only
|
@admins_only
|
||||||
|
@users_namespace.doc(
|
||||||
|
description="Endpoint to delete a specific User object",
|
||||||
|
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||||
|
)
|
||||||
def delete(self, user_id):
|
def delete(self, user_id):
|
||||||
Notifications.query.filter_by(user_id=user_id).delete()
|
Notifications.query.filter_by(user_id=user_id).delete()
|
||||||
Awards.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")
|
@users_namespace.route("/me")
|
||||||
class UserPrivate(Resource):
|
class UserPrivate(Resource):
|
||||||
@authed_only
|
@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):
|
def get(self):
|
||||||
user = get_current_user()
|
user = get_current_user()
|
||||||
response = UserSchema("self").dump(user).data
|
response = UserSchema("self").dump(user).data
|
||||||
@@ -146,6 +277,16 @@ class UserPrivate(Resource):
|
|||||||
return {"success": True, "data": response}
|
return {"success": True, "data": response}
|
||||||
|
|
||||||
@authed_only
|
@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):
|
def patch(self):
|
||||||
user = get_current_user()
|
user = get_current_user()
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
@@ -294,6 +435,10 @@ class UserPublicAwards(Resource):
|
|||||||
@users_namespace.param("user_id", "User ID")
|
@users_namespace.param("user_id", "User ID")
|
||||||
class UserEmails(Resource):
|
class UserEmails(Resource):
|
||||||
@admins_only
|
@admins_only
|
||||||
|
@users_namespace.doc(
|
||||||
|
description="Endpoint to email a User object",
|
||||||
|
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||||
|
)
|
||||||
@ratelimit(method="POST", limit=10, interval=60)
|
@ratelimit(method="POST", limit=10, interval=60)
|
||||||
def post(self, user_id):
|
def post(self, user_id):
|
||||||
req = request.get_json()
|
req = request.get_json()
|
||||||
@@ -314,4 +459,4 @@ class UserEmails(Resource):
|
|||||||
|
|
||||||
result, response = sendmail(addr=user.email, text=text)
|
result, response = sendmail(addr=user.email, text=text)
|
||||||
|
|
||||||
return {"success": result, "data": {}}
|
return {"success": result}
|
||||||
|
|||||||
36
CTFd/auth.py
36
CTFd/auth.py
@@ -6,10 +6,10 @@ from flask import current_app as app
|
|||||||
from flask import redirect, render_template, request, session, url_for
|
from flask import redirect, render_template, request, session, url_for
|
||||||
from itsdangerous.exc import BadSignature, BadTimeSignature, SignatureExpired
|
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.models import Teams, Users, db
|
||||||
from CTFd.utils import config, email, get_app_config, get_config
|
from CTFd.utils import config, email, get_app_config, get_config
|
||||||
from CTFd.utils import user as current_user
|
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 import validators
|
||||||
from CTFd.utils.config import is_teams_mode
|
from CTFd.utils.config import is_teams_mode
|
||||||
from CTFd.utils.config.integrations import mlc_registration
|
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.crypto import verify_password
|
||||||
from CTFd.utils.decorators import ratelimit
|
from CTFd.utils.decorators import ratelimit
|
||||||
from CTFd.utils.decorators.visibility import check_registration_visibility
|
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.logging import log
|
||||||
from CTFd.utils.modes import TEAMS_MODE
|
from CTFd.utils.modes import TEAMS_MODE
|
||||||
from CTFd.utils.security.auth import login_user, logout_user
|
from CTFd.utils.security.auth import login_user, logout_user
|
||||||
@@ -66,7 +66,7 @@ def confirm(data=None):
|
|||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
# User is trying to start or restart the confirmation flow
|
# 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"))
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
user = Users.query.filter_by(id=session["id"]).first_or_404()
|
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",
|
format="[{date}] {ip} - {name} initiated a confirmation email resend",
|
||||||
)
|
)
|
||||||
return render_template(
|
return render_template(
|
||||||
"confirm.html",
|
"confirm.html", infos=[f"Confirmation email sent to {user.email}!"]
|
||||||
user=user,
|
|
||||||
infos=["Your confirmation email has been resent!"],
|
|
||||||
)
|
)
|
||||||
elif request.method == "GET":
|
elif request.method == "GET":
|
||||||
# User has been directed to the confirm page
|
# 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", methods=["POST", "GET"])
|
||||||
@auth.route("/reset_password/<data>", methods=["POST", "GET"])
|
@auth.route("/reset_password/<data>", methods=["POST", "GET"])
|
||||||
@ratelimit(method="POST", limit=10, interval=60)
|
@ratelimit(method="POST", limit=10, interval=60)
|
||||||
def reset_password(data=None):
|
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:
|
if data is not None:
|
||||||
try:
|
try:
|
||||||
email_address = unserialize(data, max_age=1800)
|
email_address = unserialize(data, max_age=1800)
|
||||||
@@ -115,7 +123,7 @@ def reset_password(data=None):
|
|||||||
if user.oauth_id:
|
if user.oauth_id:
|
||||||
return render_template(
|
return render_template(
|
||||||
"reset_password.html",
|
"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."
|
"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()
|
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:
|
if not user:
|
||||||
return render_template(
|
return render_template(
|
||||||
"reset_password.html",
|
"reset_password.html",
|
||||||
errors=[
|
infos=[
|
||||||
"If that account exists you will receive an email, please check your inbox"
|
"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:
|
if user.oauth_id:
|
||||||
return render_template(
|
return render_template(
|
||||||
"reset_password.html",
|
"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."
|
"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(
|
return render_template(
|
||||||
"reset_password.html",
|
"reset_password.html",
|
||||||
errors=[
|
infos=[
|
||||||
"If that account exists you will receive an email, please check your inbox"
|
"If that account exists you will receive an email, please check your inbox"
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
17
CTFd/cache/__init__.py
vendored
17
CTFd/cache/__init__.py
vendored
@@ -30,14 +30,31 @@ def clear_standings():
|
|||||||
from CTFd.utils.scores import get_standings, get_team_standings, get_user_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.v1.scoreboard import ScoreboardDetail, ScoreboardList
|
||||||
from CTFd.api import api
|
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_standings)
|
||||||
cache.delete_memoized(get_team_standings)
|
cache.delete_memoized(get_team_standings)
|
||||||
cache.delete_memoized(get_user_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_score)
|
||||||
cache.delete_memoized(Users.get_place)
|
cache.delete_memoized(Users.get_place)
|
||||||
cache.delete_memoized(Teams.get_score)
|
cache.delete_memoized(Teams.get_score)
|
||||||
cache.delete_memoized(Teams.get_place)
|
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="scoreboard.listing"))
|
||||||
cache.delete(make_cache_key(path=api.name + "." + ScoreboardList.endpoint))
|
cache.delete(make_cache_key(path=api.name + "." + ScoreboardList.endpoint))
|
||||||
cache.delete(make_cache_key(path=api.name + "." + ScoreboardDetail.endpoint))
|
cache.delete(make_cache_key(path=api.name + "." + ScoreboardDetail.endpoint))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from flask import Blueprint, render_template
|
from flask import Blueprint, render_template
|
||||||
|
|
||||||
from CTFd.utils import config, get_config
|
from CTFd.utils import config
|
||||||
from CTFd.utils.dates import ctf_ended, ctf_paused, view_after_ctf
|
from CTFd.utils.dates import ctf_ended, ctf_paused, ctf_started
|
||||||
from CTFd.utils.decorators import (
|
from CTFd.utils.decorators import (
|
||||||
during_ctf_time_only,
|
during_ctf_time_only,
|
||||||
require_team,
|
require_team,
|
||||||
@@ -21,16 +21,14 @@ challenges = Blueprint("challenges", __name__)
|
|||||||
def listing():
|
def listing():
|
||||||
infos = get_infos()
|
infos = get_infos()
|
||||||
errors = get_errors()
|
errors = get_errors()
|
||||||
start = get_config("start") or 0
|
|
||||||
end = get_config("end") or 0
|
|
||||||
|
|
||||||
if ctf_paused():
|
if ctf_started() is False:
|
||||||
infos.append("{} is paused".format(config.ctf_name()))
|
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_paused() is True:
|
||||||
if ctf_ended() and view_after_ctf():
|
infos.append(f"{config.ctf_name()} is paused")
|
||||||
infos.append("{} has ended".format(config.ctf_name()))
|
|
||||||
|
|
||||||
return render_template(
|
if ctf_ended() is True:
|
||||||
"challenges.html", infos=infos, errors=errors, start=int(start), end=int(end)
|
infos.append(f"{config.ctf_name()} has ended")
|
||||||
)
|
|
||||||
|
return render_template("challenges.html", infos=infos, errors=errors)
|
||||||
|
|||||||
48
CTFd/config.ini
Normal file
48
CTFd/config.ini
Normal 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 =
|
||||||
215
CTFd/config.py
215
CTFd/config.py
@@ -1,8 +1,28 @@
|
|||||||
|
import configparser
|
||||||
import os
|
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
|
# Attempt to read the secret from the secret file
|
||||||
# This will fail if the secret has not been written
|
# This will fail if the secret has not been written
|
||||||
try:
|
try:
|
||||||
@@ -21,11 +41,15 @@ if not os.getenv("SECRET_KEY"):
|
|||||||
secret.flush()
|
secret.flush()
|
||||||
except (OSError, IOError):
|
except (OSError, IOError):
|
||||||
pass
|
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):
|
class Config(object):
|
||||||
"""
|
"""
|
||||||
CTFd Configuration Object
|
CTFd Configuration Object
|
||||||
@@ -62,33 +86,37 @@ class Config(object):
|
|||||||
e.g. redis://user:password@localhost:6379
|
e.g. redis://user:password@localhost:6379
|
||||||
http://pythonhosted.org/Flask-Caching/#configuring-flask-caching
|
http://pythonhosted.org/Flask-Caching/#configuring-flask-caching
|
||||||
"""
|
"""
|
||||||
SECRET_KEY = os.getenv("SECRET_KEY") or key
|
SECRET_KEY: str = os.getenv("SECRET_KEY") \
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL") or "sqlite:///{}/ctfd.db".format(
|
or empty_str_cast(config_ini["server"]["SECRET_KEY"]) \
|
||||||
os.path.dirname(os.path.abspath(__file__))
|
or gen_secret_key()
|
||||||
)
|
|
||||||
REDIS_URL = os.getenv("REDIS_URL")
|
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
|
SQLALCHEMY_DATABASE_URI = DATABASE_URL
|
||||||
CACHE_REDIS_URL = REDIS_URL
|
CACHE_REDIS_URL = REDIS_URL
|
||||||
if CACHE_REDIS_URL:
|
if CACHE_REDIS_URL:
|
||||||
CACHE_TYPE = "redis"
|
CACHE_TYPE: str = "redis"
|
||||||
else:
|
else:
|
||||||
CACHE_TYPE = "filesystem"
|
CACHE_TYPE: str = "filesystem"
|
||||||
CACHE_DIR = os.path.join(
|
CACHE_DIR: str = os.path.join(
|
||||||
os.path.dirname(__file__), os.pardir, ".data", "filesystem_cache"
|
os.path.dirname(__file__), os.pardir, ".data", "filesystem_cache"
|
||||||
)
|
)
|
||||||
CACHE_THRESHOLD = (
|
# Override the threshold of cached values on the filesystem. The default is 500. Don't change unless you know what you're doing.
|
||||||
0
|
CACHE_THRESHOLD: int = 0
|
||||||
) # Override the threshold of cached values on the filesystem. The default is 500. Don't change unless you know what you're doing.
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
=== SECURITY ===
|
=== SECURITY ===
|
||||||
|
|
||||||
SESSION_COOKIE_HTTPONLY:
|
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:
|
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:
|
TRUSTED_PROXIES:
|
||||||
Defines a set of regular expressions used for finding a user's IP address if the CTFd instance
|
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
|
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.
|
solely on IP addresses unless you know what you are doing.
|
||||||
"""
|
"""
|
||||||
SESSION_COOKIE_HTTPONLY = not os.getenv("SESSION_COOKIE_HTTPONLY") # Defaults True
|
SESSION_COOKIE_HTTPONLY: bool = process_boolean_str(os.getenv("SESSION_COOKIE_HTTPONLY")) \
|
||||||
SESSION_COOKIE_SAMESITE = os.getenv("SESSION_COOKIE_SAMESITE") or "Lax"
|
or config_ini["security"].getboolean("SESSION_COOKIE_HTTPONLY") \
|
||||||
PERMANENT_SESSION_LIFETIME = int(
|
or True
|
||||||
os.getenv("PERMANENT_SESSION_LIFETIME") or 604800
|
|
||||||
) # 7 days in seconds
|
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 = [
|
TRUSTED_PROXIES = [
|
||||||
r"^127\.0\.0\.1$",
|
r"^127\.0\.0\.1$",
|
||||||
# Remove the following proxies if you do not trust the local network
|
# 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
|
Whether to connect to the SMTP server over SSL
|
||||||
|
|
||||||
MAILGUN_API_KEY
|
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
|
||||||
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"
|
MAILFROM_ADDR: str = os.getenv("MAILFROM_ADDR") \
|
||||||
MAIL_SERVER = os.getenv("MAIL_SERVER") or None
|
or config_ini["email"]["MAILFROM_ADDR"] \
|
||||||
MAIL_PORT = os.getenv("MAIL_PORT")
|
or "noreply@ctfd.io"
|
||||||
MAIL_USEAUTH = os.getenv("MAIL_USEAUTH")
|
|
||||||
MAIL_USERNAME = os.getenv("MAIL_USERNAME")
|
MAIL_SERVER: str = os.getenv("MAIL_SERVER") \
|
||||||
MAIL_PASSWORD = os.getenv("MAIL_PASSWORD")
|
or empty_str_cast(config_ini["email"]["MAIL_SERVER"])
|
||||||
MAIL_TLS = os.getenv("MAIL_TLS") or False
|
|
||||||
MAIL_SSL = os.getenv("MAIL_SSL") or False
|
MAIL_PORT: str = os.getenv("MAIL_PORT") \
|
||||||
MAILGUN_API_KEY = os.getenv("MAILGUN_API_KEY")
|
or empty_str_cast(config_ini["email"]["MAIL_PORT"])
|
||||||
MAILGUN_BASE_URL = os.getenv("MAILGUN_BASE_URL")
|
|
||||||
|
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 ===
|
=== 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 location where logs are written. These are the logs for CTFd key submissions, registrations, and logins.
|
||||||
The default location is the CTFd/logs folder.
|
The default location is the CTFd/logs folder.
|
||||||
"""
|
"""
|
||||||
LOG_FOLDER = os.getenv("LOG_FOLDER") or os.path.join(
|
LOG_FOLDER: str = os.getenv("LOG_FOLDER") \
|
||||||
os.path.dirname(os.path.abspath(__file__)), "logs"
|
or empty_str_cast(config_ini["logs"]["LOG_FOLDER"]) \
|
||||||
)
|
or os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
|
||||||
|
|
||||||
"""
|
"""
|
||||||
=== UPLOADS ===
|
=== UPLOADS ===
|
||||||
@@ -191,15 +248,26 @@ class Config(object):
|
|||||||
A URL pointing to a custom S3 implementation.
|
A URL pointing to a custom S3 implementation.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
UPLOAD_PROVIDER = os.getenv("UPLOAD_PROVIDER") or "filesystem"
|
UPLOAD_PROVIDER: str = os.getenv("UPLOAD_PROVIDER") \
|
||||||
UPLOAD_FOLDER = os.getenv("UPLOAD_FOLDER") or os.path.join(
|
or empty_str_cast(config_ini["uploads"]["UPLOAD_PROVIDER"]) \
|
||||||
os.path.dirname(os.path.abspath(__file__)), "uploads"
|
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":
|
if UPLOAD_PROVIDER == "s3":
|
||||||
AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID")
|
AWS_ACCESS_KEY_ID: str = os.getenv("AWS_ACCESS_KEY_ID") \
|
||||||
AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY")
|
or empty_str_cast(config_ini["uploads"]["AWS_ACCESS_KEY_ID"])
|
||||||
AWS_S3_BUCKET = os.getenv("AWS_S3_BUCKET")
|
|
||||||
AWS_S3_ENDPOINT_URL = os.getenv("AWS_S3_ENDPOINT_URL")
|
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 ===
|
=== 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.
|
Alternatively if you specify `true` CTFd will default to the above behavior with all proxy settings set to 1.
|
||||||
|
|
||||||
TEMPLATES_AUTO_RELOAD:
|
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:
|
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:
|
SWAGGER_UI:
|
||||||
Enable the Swagger UI endpoint at /api/v1/
|
Enable the Swagger UI endpoint at /api/v1/
|
||||||
|
|
||||||
UPDATE_CHECK:
|
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:
|
APPLICATION_ROOT:
|
||||||
Specifies what path CTFd is mounted under. It can be used to run CTFd in a subdirectory.
|
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://docs.sqlalchemy.org/en/13/core/engines.html#sqlalchemy.create_engine
|
||||||
https://flask-sqlalchemy.palletsprojects.com/en/2.x/config/#configuration-keys
|
https://flask-sqlalchemy.palletsprojects.com/en/2.x/config/#configuration-keys
|
||||||
"""
|
"""
|
||||||
REVERSE_PROXY = os.getenv("REVERSE_PROXY") or False
|
REVERSE_PROXY: bool = process_boolean_str(os.getenv("REVERSE_PROXY")) \
|
||||||
TEMPLATES_AUTO_RELOAD = not os.getenv("TEMPLATES_AUTO_RELOAD") # Defaults True
|
or empty_str_cast(config_ini["optional"]["REVERSE_PROXY"]) \
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = (
|
or False
|
||||||
os.getenv("SQLALCHEMY_TRACK_MODIFICATIONS") is not None
|
|
||||||
) # Defaults False
|
TEMPLATES_AUTO_RELOAD: bool = process_boolean_str(os.getenv("TEMPLATES_AUTO_RELOAD")) \
|
||||||
SWAGGER_UI = "/" if os.getenv("SWAGGER_UI") is not None else False # Defaults False
|
or empty_str_cast(config_ini["optional"]["TEMPLATES_AUTO_RELOAD"]) \
|
||||||
UPDATE_CHECK = not os.getenv("UPDATE_CHECK") # Defaults True
|
or True
|
||||||
APPLICATION_ROOT = os.getenv("APPLICATION_ROOT") or "/"
|
|
||||||
SERVER_SENT_EVENTS = not os.getenv("SERVER_SENT_EVENTS") # Defaults 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:
|
if DATABASE_URL.startswith("sqlite") is False:
|
||||||
SQLALCHEMY_ENGINE_OPTIONS = {
|
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
|
MajorLeagueCyber Integration
|
||||||
Register an event at https://majorleaguecyber.org/ and use the Client ID and Client Secret here
|
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_ID: str = os.getenv("OAUTH_CLIENT_ID") \
|
||||||
OAUTH_CLIENT_SECRET = os.getenv("OAUTH_CLIENT_SECRET")
|
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):
|
class TestingConfig(Config):
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
JS_ENUMS = {}
|
JS_ENUMS = {}
|
||||||
|
|||||||
67
CTFd/constants/config.py
Normal file
67
CTFd/constants/config.py
Normal 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
54
CTFd/constants/plugins.py
Normal 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()
|
||||||
18
CTFd/constants/sessions.py
Normal file
18
CTFd/constants/sessions.py
Normal 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()
|
||||||
@@ -18,3 +18,26 @@ TeamAttrs = namedtuple(
|
|||||||
"created",
|
"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()
|
||||||
|
|||||||
@@ -20,3 +20,26 @@ UserAttrs = namedtuple(
|
|||||||
"created",
|
"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
49
CTFd/forms/__init__.py
Normal 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
33
CTFd/forms/auth.py
Normal 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
30
CTFd/forms/awards.py
Normal 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
30
CTFd/forms/challenges.py
Normal 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
62
CTFd/forms/config.py
Normal 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
10
CTFd/forms/email.py
Normal 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
17
CTFd/forms/fields.py
Normal 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
|
||||||
26
CTFd/forms/notifications.py
Normal file
26
CTFd/forms/notifications.py
Normal 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
33
CTFd/forms/pages.py
Normal 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
22
CTFd/forms/self.py
Normal 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
66
CTFd/forms/setup.py
Normal 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
16
CTFd/forms/submissions.py
Normal 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
82
CTFd/forms/teams.py
Normal 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
58
CTFd/forms/users.py
Normal 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)
|
||||||
@@ -6,8 +6,6 @@ from sqlalchemy.ext.hybrid import hybrid_property
|
|||||||
from sqlalchemy.orm import column_property, validates
|
from sqlalchemy.orm import column_property, validates
|
||||||
|
|
||||||
from CTFd.cache import cache
|
from CTFd.cache import cache
|
||||||
from CTFd.utils.crypto import hash_password
|
|
||||||
from CTFd.utils.humanize.numbers import ordinalize
|
|
||||||
|
|
||||||
db = SQLAlchemy()
|
db = SQLAlchemy()
|
||||||
ma = Marshmallow()
|
ma = Marshmallow()
|
||||||
@@ -81,6 +79,13 @@ class Challenges(db.Model):
|
|||||||
|
|
||||||
__mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type}
|
__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):
|
def __init__(self, *args, **kwargs):
|
||||||
super(Challenges, self).__init__(**kwargs)
|
super(Challenges, self).__init__(**kwargs)
|
||||||
|
|
||||||
@@ -256,6 +261,8 @@ class Users(db.Model):
|
|||||||
|
|
||||||
@validates("password")
|
@validates("password")
|
||||||
def validate_password(self, key, plaintext):
|
def validate_password(self, key, plaintext):
|
||||||
|
from CTFd.utils.crypto import hash_password
|
||||||
|
|
||||||
return hash_password(str(plaintext))
|
return hash_password(str(plaintext))
|
||||||
|
|
||||||
@hybrid_property
|
@hybrid_property
|
||||||
@@ -268,6 +275,16 @@ class Users(db.Model):
|
|||||||
elif user_mode == "users":
|
elif user_mode == "users":
|
||||||
return self.id
|
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
|
@property
|
||||||
def solves(self):
|
def solves(self):
|
||||||
return self.get_solves(admin=False)
|
return self.get_solves(admin=False)
|
||||||
@@ -365,6 +382,7 @@ class Users(db.Model):
|
|||||||
application itself will result in a circular import.
|
application itself will result in a circular import.
|
||||||
"""
|
"""
|
||||||
from CTFd.utils.scores import get_user_standings
|
from CTFd.utils.scores import get_user_standings
|
||||||
|
from CTFd.utils.humanize.numbers import ordinalize
|
||||||
|
|
||||||
standings = get_user_standings(admin=admin)
|
standings = get_user_standings(admin=admin)
|
||||||
|
|
||||||
@@ -418,6 +436,8 @@ class Teams(db.Model):
|
|||||||
|
|
||||||
@validates("password")
|
@validates("password")
|
||||||
def validate_password(self, key, plaintext):
|
def validate_password(self, key, plaintext):
|
||||||
|
from CTFd.utils.crypto import hash_password
|
||||||
|
|
||||||
return hash_password(str(plaintext))
|
return hash_password(str(plaintext))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -509,6 +529,7 @@ class Teams(db.Model):
|
|||||||
application itself will result in a circular import.
|
application itself will result in a circular import.
|
||||||
"""
|
"""
|
||||||
from CTFd.utils.scores import get_team_standings
|
from CTFd.utils.scores import get_team_standings
|
||||||
|
from CTFd.utils.humanize.numbers import ordinalize
|
||||||
|
|
||||||
standings = get_team_standings(admin=admin)
|
standings = get_team_standings(admin=admin)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import os
|
|||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
|
||||||
from flask import current_app as app
|
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.config.pages import get_pages
|
||||||
from CTFd.utils.decorators import admins_only as admins_only_wrapper
|
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
|
:param route: A string that is the href used by the link
|
||||||
:return:
|
: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)
|
am = Menu(title=title, route=route)
|
||||||
app.admin_plugin_menu_bar.append(am)
|
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
|
:param route: A string that is the href used by the link
|
||||||
:return:
|
: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)
|
p = Menu(title=title, route=route)
|
||||||
app.plugin_menu_bar.append(p)
|
app.plugin_menu_bar.append(p)
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from CTFd.models import (
|
|||||||
db,
|
db,
|
||||||
)
|
)
|
||||||
from CTFd.plugins import register_plugin_assets_directory
|
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.uploads import delete_file
|
||||||
from CTFd.utils.user import get_ip
|
from CTFd.utils.user import get_ip
|
||||||
|
|
||||||
@@ -21,6 +21,153 @@ class BaseChallenge(object):
|
|||||||
name = None
|
name = None
|
||||||
templates = {}
|
templates = {}
|
||||||
scripts = {}
|
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):
|
class CTFdStandardChallenge(BaseChallenge):
|
||||||
@@ -42,151 +189,7 @@ class CTFdStandardChallenge(BaseChallenge):
|
|||||||
blueprint = Blueprint(
|
blueprint = Blueprint(
|
||||||
"standard", __name__, template_folder="templates", static_folder="assets"
|
"standard", __name__, template_folder="templates", static_folder="assets"
|
||||||
)
|
)
|
||||||
|
challenge_model = Challenges
|
||||||
@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()
|
|
||||||
|
|
||||||
|
|
||||||
def get_chal_class(class_id):
|
def get_chal_class(class_id):
|
||||||
|
|||||||
@@ -1,64 +1 @@
|
|||||||
<form method="POST" action="{{ script_root }}/admin/challenges/new" enctype="multipart/form-data">
|
{% extends "admin/challenges/create.html" %}
|
||||||
<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>
|
|
||||||
@@ -1,39 +1,4 @@
|
|||||||
CTFd.plugin.run((_CTFd) => {
|
CTFd.plugin.run((_CTFd) => {
|
||||||
const $ = _CTFd.lib.$
|
const $ = _CTFd.lib.$
|
||||||
const md = _CTFd.lib.markdown()
|
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();
|
|
||||||
// });
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,64 +1 @@
|
|||||||
<form method="POST">
|
{% extends "admin/challenges/update.html" %}
|
||||||
<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>
|
|
||||||
@@ -1,117 +1,16 @@
|
|||||||
<div class="modal-dialog" role="document">
|
{% extends "challenge.html" %}
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-body">
|
|
||||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
|
||||||
<span aria-hidden="true">×</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>
|
|
||||||
|
|
||||||
<div class="row submit-row">
|
{% block description %}
|
||||||
<div class="col-md-9 form-group">
|
{{ challenge.html }}
|
||||||
<input class="form-control" type="text" name="answer" id="submission-input" placeholder="Flag"/>
|
{% endblock %}
|
||||||
<input id="challenge-id" type="hidden" value="{{ id }}">
|
|
||||||
</div>
|
{% block input %}
|
||||||
<div class="col-md-3 form-group key-submit">
|
<input id="challenge-id" class="challenge-id" type="hidden" value="{{ challenge.id }}">
|
||||||
<button type="submit" id="submit-key" tabindex="0"
|
<input id="challenge-input" class="challenge-input" type="text" name="answer" placeholder="Flag"/>
|
||||||
class="btn btn-md btn-outline-secondary float-right">Submit
|
{% endblock %}
|
||||||
</button>
|
|
||||||
</div>
|
{% block submit %}
|
||||||
</div>
|
<button id="challenge-submit" class="challenge-submit" type="submit">
|
||||||
<div class="row notification-row">
|
Submit
|
||||||
<div class="col-md-12">
|
</button>
|
||||||
<div id="result-notification" class="alert alert-dismissable text-center w-100"
|
{% endblock %}
|
||||||
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>
|
|
||||||
@@ -15,7 +15,7 @@ CTFd._internal.challenge.postRender = function () { }
|
|||||||
|
|
||||||
CTFd._internal.challenge.submit = function (preview) {
|
CTFd._internal.challenge.submit = function (preview) {
|
||||||
var challenge_id = parseInt(CTFd.lib.$('#challenge-id').val())
|
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 = {
|
var body = {
|
||||||
'challenge_id': challenge_id,
|
'challenge_id': challenge_id,
|
||||||
|
|||||||
@@ -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:
|
The current implementation requires the challenge to keep track of three values:
|
||||||
|
|
||||||
* Initial - The original point valuation
|
- Initial - The original point valuation
|
||||||
* Decay - The amount of solves before the challenge will be at the minimum
|
- Decay - The amount of solves before the challenge will be at the minimum
|
||||||
* Minimum - The lowest possible point valuation
|
- Minimum - The lowest possible point valuation
|
||||||
|
|
||||||
The value decay logic is implemented with the following math:
|
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**
|
**REQUIRES: CTFd >= v1.2.0**
|
||||||
|
|
||||||
1. Clone this repository to `CTFd/plugins`. It is important that the folder is
|
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`
|
named `DynamicValueChallenge` so CTFd can serve the files in the `assets`
|
||||||
directory.
|
directory.
|
||||||
|
|||||||
@@ -4,23 +4,25 @@ import math
|
|||||||
|
|
||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
|
|
||||||
from CTFd.models import (
|
from CTFd.models import Challenges, Solves, db
|
||||||
ChallengeFiles,
|
|
||||||
Challenges,
|
|
||||||
Fails,
|
|
||||||
Flags,
|
|
||||||
Hints,
|
|
||||||
Solves,
|
|
||||||
Tags,
|
|
||||||
db,
|
|
||||||
)
|
|
||||||
from CTFd.plugins import register_plugin_assets_directory
|
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.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.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):
|
class DynamicValueChallenge(BaseChallenge):
|
||||||
@@ -45,6 +47,7 @@ class DynamicValueChallenge(BaseChallenge):
|
|||||||
template_folder="templates",
|
template_folder="templates",
|
||||||
static_folder="assets",
|
static_folder="assets",
|
||||||
)
|
)
|
||||||
|
challenge_model = DynamicChallenge
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def calculate_value(cls, challenge):
|
def calculate_value(cls, challenge):
|
||||||
@@ -82,24 +85,8 @@ class DynamicValueChallenge(BaseChallenge):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
return challenge
|
return challenge
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def create(request):
|
def read(cls, challenge):
|
||||||
"""
|
|
||||||
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):
|
|
||||||
"""
|
"""
|
||||||
This method is in used to access the data of a challenge in a format processable by the front end.
|
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,
|
"max_attempts": challenge.max_attempts,
|
||||||
"type": challenge.type,
|
"type": challenge.type,
|
||||||
"type_data": {
|
"type_data": {
|
||||||
"id": DynamicValueChallenge.id,
|
"id": cls.id,
|
||||||
"name": DynamicValueChallenge.name,
|
"name": cls.name,
|
||||||
"templates": DynamicValueChallenge.templates,
|
"templates": cls.templates,
|
||||||
"scripts": DynamicValueChallenge.scripts,
|
"scripts": cls.scripts,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def update(challenge, request):
|
def update(cls, challenge, request):
|
||||||
"""
|
"""
|
||||||
This method is used to update the information associated with a challenge. This should be kept strictly to the
|
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.
|
Challenges table and any child tables.
|
||||||
@@ -148,109 +135,12 @@ class DynamicValueChallenge(BaseChallenge):
|
|||||||
|
|
||||||
return DynamicValueChallenge.calculate_value(challenge)
|
return DynamicValueChallenge.calculate_value(challenge)
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def delete(challenge):
|
def solve(cls, user, team, challenge, request):
|
||||||
"""
|
super().solve(user, team, challenge, request)
|
||||||
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()
|
|
||||||
|
|
||||||
DynamicValueChallenge.calculate_value(challenge)
|
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):
|
def load(app):
|
||||||
upgrade()
|
upgrade()
|
||||||
|
|||||||
@@ -1,88 +1,43 @@
|
|||||||
<form method="POST" action="{{ script_root }}/admin/chal/new" enctype="multipart/form-data">
|
{% extends "admin/challenges/create.html" %}
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="form-group">
|
{% block header %}
|
||||||
<label for="name">Name<br>
|
<div class="alert alert-secondary" role="alert">
|
||||||
<small class="form-text text-muted">
|
Dynamic value challenges decrease in value as they receive solves. The more solves a dynamic challenge has,
|
||||||
The name of your challenge
|
the lower its value is to everyone who has solved it.
|
||||||
</small>
|
</div>
|
||||||
</label>
|
{% endblock %}
|
||||||
<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>
|
|
||||||
|
|
||||||
<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">
|
{% block value %}
|
||||||
<div role="tabpanel" class="tab-pane active" id="new-desc-write">
|
<div class="form-group">
|
||||||
<div class="form-group">
|
<label for="value">Initial Value<br>
|
||||||
<label for="message-text" class="control-label">Message
|
<small class="form-text text-muted">
|
||||||
<small class="form-text text-muted">
|
This is how many points the challenge is worth initially.
|
||||||
Use this to give a brief introduction to your challenge. The description supports HTML and
|
</small>
|
||||||
Markdown.
|
</label>
|
||||||
</small>
|
<input type="number" class="form-control" name="value" placeholder="Enter value" required>
|
||||||
</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">
|
</div>
|
||||||
<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 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">
|
<div class="form-group">
|
||||||
<label for="value">Decay Limit<br>
|
<label for="value">Minimum Value<br>
|
||||||
<small class="form-text text-muted">
|
<small class="form-text text-muted">
|
||||||
The amount of solves before the challenge reaches its minimum value
|
This is the lowest that the challenge can be worth
|
||||||
</small>
|
</small>
|
||||||
</label>
|
</label>
|
||||||
<input type="number" class="form-control" name="decay" placeholder="Enter decay limit" required>
|
<input type="number" class="form-control" name="minimum" placeholder="Enter minimum value" required>
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
<div class="form-group">
|
{% block type %}
|
||||||
<label for="value">Minimum Value<br>
|
<input type="hidden" value="dynamic" name="type" id="chaltype">
|
||||||
<small class="form-text text-muted">
|
{% endblock %}
|
||||||
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>
|
|
||||||
@@ -1,29 +1,12 @@
|
|||||||
// Markdown Preview
|
CTFd.plugin.run((_CTFd) => {
|
||||||
$('#desc-edit').on('shown.bs.tab', function (event) {
|
const $ = _CTFd.lib.$
|
||||||
if (event.target.hash == '#desc-preview'){
|
const md = _CTFd.lib.markdown()
|
||||||
var editor_value = $('#desc-editor').val();
|
$('a[href="#new-desc-preview"]').on('shown.bs.tab', function (event) {
|
||||||
$(event.target.hash).html(
|
if (event.target.hash == '#new-desc-preview') {
|
||||||
window.challenge.render(editor_value)
|
var editor_value = $('#new-desc-editor').val();
|
||||||
);
|
$(event.target.hash).html(
|
||||||
}
|
md.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();
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,91 +1,39 @@
|
|||||||
<form method="POST">
|
{% extends "admin/challenges/update.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 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>
|
|
||||||
|
|
||||||
<div class="form-group">
|
{% block value %}
|
||||||
<label for="message-text" class="control-label">Message<br>
|
<div class="form-group">
|
||||||
<small class="form-text text-muted">
|
<label for="value">Current Value<br>
|
||||||
Use this to give a brief introduction to your challenge.
|
<small class="form-text text-muted">
|
||||||
</small>
|
This is how many points the challenge is worth right now.
|
||||||
</label>
|
</small>
|
||||||
<textarea id="desc-editor" class="form-control chal-desc-editor" name="description" rows="10">{{ challenge.description }}</textarea>
|
</label>
|
||||||
</div>
|
<input type="number" class="form-control chal-value" name="value" value="{{ challenge.value }}" disabled>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="value">Current Value<br>
|
<label for="value">Initial Value<br>
|
||||||
<small class="form-text text-muted">
|
<small class="form-text text-muted">
|
||||||
This is how many points the challenge is worth right now.
|
This is how many points the challenge was worth initially.
|
||||||
</small>
|
</small>
|
||||||
</label>
|
</label>
|
||||||
<input type="number" class="form-control chal-value" name="value" value="{{ challenge.value }}" disabled>
|
<input type="number" class="form-control chal-initial" name="initial" value="{{ challenge.initial }}" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="value">Initial Value<br>
|
<label for="value">Decay Limit<br>
|
||||||
<small class="form-text text-muted">
|
<small class="form-text text-muted">
|
||||||
This is how many points the challenge was worth initially.
|
The amount of solves before the challenge reaches its minimum value
|
||||||
</small>
|
</small>
|
||||||
</label>
|
</label>
|
||||||
<input type="number" class="form-control chal-initial" name="initial" value="{{ challenge.initial }}" required>
|
<input type="number" class="form-control chal-decay" name="decay" value="{{ challenge.decay }}" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="value">Decay Limit<br>
|
<label for="value">Minimum Value<br>
|
||||||
<small class="form-text text-muted">
|
<small class="form-text text-muted">
|
||||||
The amount of solves before the challenge reaches its minimum value
|
This is the lowest that the challenge can be worth
|
||||||
</small>
|
</small>
|
||||||
</label>
|
</label>
|
||||||
<input type="number" class="form-control chal-decay" name="decay" value="{{ challenge.decay }}" required>
|
<input type="number" class="form-control chal-minimum" name="minimum" value="{{ challenge.minimum }}" required>
|
||||||
</div>
|
</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 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>
|
|
||||||
@@ -1,118 +1,16 @@
|
|||||||
<div class="modal-dialog" role="document">
|
{% extends "challenge.html" %}
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-body">
|
|
||||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
|
||||||
<span aria-hidden="true">×</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>
|
|
||||||
|
|
||||||
<div class="row submit-row">
|
{% block description %}
|
||||||
<div class="col-md-9 form-group">
|
{{ challenge.html }}
|
||||||
<input class="form-control" type="text" name="answer" id="submission-input"
|
{% endblock %}
|
||||||
placeholder="Flag"/>
|
|
||||||
<input id="challenge-id" type="hidden" value="{{ id }}">
|
{% block input %}
|
||||||
</div>
|
<input id="challenge-id" class="challenge-id" type="hidden" value="{{ challenge.id }}">
|
||||||
<div class="col-md-3 form-group key-submit">
|
<input id="challenge-input" class="challenge-input" type="text" name="answer" placeholder="Flag"/>
|
||||||
<button type="submit" id="submit-key" tabindex="0"
|
{% endblock %}
|
||||||
class="btn btn-md btn-outline-secondary float-right">Submit
|
|
||||||
</button>
|
{% block submit %}
|
||||||
</div>
|
<button id="challenge-submit" class="challenge-submit" type="submit">
|
||||||
</div>
|
Submit
|
||||||
<div class="row notification-row">
|
</button>
|
||||||
<div class="col-md-12">
|
{% endblock %}
|
||||||
<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>
|
|
||||||
@@ -3,6 +3,14 @@ import re
|
|||||||
from CTFd.plugins import register_plugin_assets_directory
|
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):
|
class BaseFlag(object):
|
||||||
name = None
|
name = None
|
||||||
templates = {}
|
templates = {}
|
||||||
@@ -55,8 +63,8 @@ class CTFdRegexFlag(BaseFlag):
|
|||||||
else:
|
else:
|
||||||
res = re.match(saved, provided)
|
res = re.match(saved, provided)
|
||||||
# TODO: this needs plugin improvements. See #1425.
|
# TODO: this needs plugin improvements. See #1425.
|
||||||
except re.error:
|
except re.error as e:
|
||||||
return False
|
raise FlagException("Regex parse error occured") from e
|
||||||
|
|
||||||
return res and res.group() == provided
|
return res and res.group() == provided
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from flask import Blueprint, render_template
|
|||||||
from CTFd.cache import cache, make_cache_key
|
from CTFd.cache import cache, make_cache_key
|
||||||
from CTFd.utils import config
|
from CTFd.utils import config
|
||||||
from CTFd.utils.decorators.visibility import check_score_visibility
|
from CTFd.utils.decorators.visibility import check_score_visibility
|
||||||
|
from CTFd.utils.helpers import get_infos
|
||||||
from CTFd.utils.scores import get_standings
|
from CTFd.utils.scores import get_standings
|
||||||
|
|
||||||
scoreboard = Blueprint("scoreboard", __name__)
|
scoreboard = Blueprint("scoreboard", __name__)
|
||||||
@@ -12,9 +13,10 @@ scoreboard = Blueprint("scoreboard", __name__)
|
|||||||
@check_score_visibility
|
@check_score_visibility
|
||||||
@cache.cached(timeout=60, key_prefix=make_cache_key)
|
@cache.cached(timeout=60, key_prefix=make_cache_key)
|
||||||
def listing():
|
def listing():
|
||||||
|
infos = get_infos()
|
||||||
|
|
||||||
|
if config.is_scoreboard_frozen():
|
||||||
|
infos.append("Scoreboard has been frozen")
|
||||||
|
|
||||||
standings = get_standings()
|
standings = get_standings()
|
||||||
return render_template(
|
return render_template("scoreboard.html", standings=standings, infos=infos)
|
||||||
"scoreboard.html",
|
|
||||||
standings=standings,
|
|
||||||
score_frozen=config.is_scoreboard_frozen(),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from flask import Blueprint, redirect, render_template, request, url_for
|
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.models import Teams, db
|
||||||
from CTFd.utils import config, get_config
|
from CTFd.utils import config, get_config
|
||||||
from CTFd.utils.crypto import verify_password
|
from CTFd.utils.crypto import verify_password
|
||||||
@@ -20,25 +20,34 @@ teams = Blueprint("teams", __name__)
|
|||||||
@check_account_visibility
|
@check_account_visibility
|
||||||
@require_team_mode
|
@require_team_mode
|
||||||
def listing():
|
def listing():
|
||||||
page = abs(request.args.get("page", 1, type=int))
|
q = request.args.get("q")
|
||||||
results_per_page = 50
|
field = request.args.get("field", "name")
|
||||||
page_start = results_per_page * (page - 1)
|
filters = []
|
||||||
page_end = results_per_page * (page - 1) + results_per_page
|
|
||||||
|
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 = (
|
||||||
Teams.query.filter_by(hidden=False, banned=False)
|
Teams.query.filter_by(hidden=False, banned=False)
|
||||||
.slice(page_start, page_end)
|
.filter(*filters)
|
||||||
.all()
|
.order_by(Teams.id.asc())
|
||||||
|
.paginate(per_page=50)
|
||||||
)
|
)
|
||||||
|
|
||||||
pages = int(count / results_per_page) + (count % results_per_page > 0)
|
args = dict(request.args)
|
||||||
return render_template("teams/teams.html", teams=teams, pages=pages, curr_page=page)
|
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"])
|
@teams.route("/teams/join", methods=["GET", "POST"])
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
@import "includes/sticky-footer.css";
|
@import "includes/sticky-footer.css";
|
||||||
|
|
||||||
#score-graph {
|
#score-graph {
|
||||||
height: 450px;
|
min-height: 400px;
|
||||||
display: block;
|
display: block;
|
||||||
clear: both;
|
clear: both;
|
||||||
}
|
}
|
||||||
@@ -12,17 +12,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#keys-pie-graph {
|
#keys-pie-graph {
|
||||||
height: 400px;
|
min-height: 400px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
#categories-pie-graph {
|
#categories-pie-graph {
|
||||||
height: 400px;
|
min-height: 400px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
#solve-percentages-graph {
|
#solve-percentages-graph {
|
||||||
height: 400px;
|
min-height: 400px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#score-distribution-graph {
|
||||||
|
min-height: 400px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,6 @@
|
|||||||
#challenge-window .form-control:focus {
|
#challenge-window .form-control:focus {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border-color: #a3d39c;
|
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;
|
transition: background-color 0.3s, border-color 0.3s;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
@import "~codemirror/lib/codemirror.css";
|
@import "~codemirror/lib/codemirror.css";
|
||||||
.CodeMirror {
|
@import "includes/easymde.scss";
|
||||||
|
.CodeMirror.cm-s-default {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
border: 1px solid lightgray;
|
||||||
}
|
}
|
||||||
|
|||||||
382
CTFd/themes/admin/assets/css/includes/easymde.scss
Normal file
382
CTFd/themes/admin/assets/css/includes/easymde.scss
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import $ from "jquery";
|
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 CTFd from "core/CTFd";
|
||||||
import nunjucks from "nunjucks";
|
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();
|
window.challenge = new Object();
|
||||||
$.get(CTFd.config.urlRoot + "/api/v1/challenges/" + CHALLENGE_ID, function(
|
$.get(
|
||||||
response
|
CTFd.config.urlRoot + "/api/v1/challenges/" + window.CHALLENGE_ID,
|
||||||
) {
|
function(response) {
|
||||||
const challenge_data = response.data;
|
const challenge_data = response.data;
|
||||||
challenge_data["solves"] = null;
|
challenge_data["solves"] = null;
|
||||||
|
|
||||||
$.getScript(
|
$.getScript(
|
||||||
CTFd.config.urlRoot + challenge_data.type_data.scripts.view,
|
CTFd.config.urlRoot + challenge_data.type_data.scripts.view,
|
||||||
function() {
|
function() {
|
||||||
$.get(
|
$.get(
|
||||||
CTFd.config.urlRoot + challenge_data.type_data.templates.view,
|
CTFd.config.urlRoot + challenge_data.type_data.templates.view,
|
||||||
function(template_data) {
|
function(template_data) {
|
||||||
$("#challenge-window").empty();
|
$("#challenge-window").empty();
|
||||||
const template = nunjucks.compile(template_data);
|
const template = nunjucks.compile(template_data);
|
||||||
window.challenge.data = challenge_data;
|
window.challenge.data = challenge_data;
|
||||||
window.challenge.preRender();
|
window.challenge.preRender();
|
||||||
|
|
||||||
challenge_data["description"] = window.challenge.render(
|
challenge_data["description"] = window.challenge.render(
|
||||||
challenge_data["description"]
|
challenge_data["description"]
|
||||||
);
|
);
|
||||||
challenge_data["script_root"] = CTFd.config.urlRoot;
|
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) {
|
$(".nav-tabs a").click(function(event) {
|
||||||
getsolves($("#challenge-id").val());
|
event.preventDefault();
|
||||||
});
|
$(this).tab("show");
|
||||||
$(".nav-tabs a").click(function(event) {
|
});
|
||||||
event.preventDefault();
|
|
||||||
$(this).tab("show");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle modal toggling
|
// Handle modal toggling
|
||||||
$("#challenge-window").on("hide.bs.modal", function(event) {
|
$("#challenge-window").on("hide.bs.modal", function(_event) {
|
||||||
$("#submission-input").removeClass("wrong");
|
$("#submission-input").removeClass("wrong");
|
||||||
$("#submission-input").removeClass("correct");
|
$("#submission-input").removeClass("correct");
|
||||||
$("#incorrect-key").slideUp();
|
$("#incorrect-key").slideUp();
|
||||||
$("#correct-key").slideUp();
|
$("#correct-key").slideUp();
|
||||||
$("#already-solved").slideUp();
|
$("#already-solved").slideUp();
|
||||||
$("#too-fast").slideUp();
|
$("#too-fast").slideUp();
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#submit-key").click(function(event) {
|
$("#submit-key").click(function(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
$("#submit-key").addClass("disabled-button");
|
$("#submit-key").addClass("disabled-button");
|
||||||
$("#submit-key").prop("disabled", true);
|
$("#submit-key").prop("disabled", true);
|
||||||
window.challenge.submit(function(data) {
|
window.challenge.submit(function(data) {
|
||||||
renderSubmissionResponse(data);
|
renderSubmissionResponse(data);
|
||||||
}, true);
|
}, true);
|
||||||
// Preview passed as true
|
// Preview passed as true
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#submission-input").keyup(function(event) {
|
$("#submission-input").keyup(function(event) {
|
||||||
if (event.keyCode == 13) {
|
if (event.keyCode == 13) {
|
||||||
$("#submit-key").click();
|
$("#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");
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
|
||||||
window.challenge.postRender();
|
window.challenge.postRender();
|
||||||
window.location.replace(
|
window.location.replace(
|
||||||
window.location.href.split("#")[0] + "#preview"
|
window.location.href.split("#")[0] + "#preview"
|
||||||
);
|
);
|
||||||
|
|
||||||
$("#challenge-window").modal();
|
$("#challenge-window").modal();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
$(".delete-challenge").click(function(event) {
|
$(".delete-challenge").click(function(_event) {
|
||||||
ezQuery({
|
ezQuery({
|
||||||
title: "Delete Challenge",
|
title: "Delete Challenge",
|
||||||
body: "Are you sure you want to delete {0}".format(
|
body: "Are you sure you want to delete {0}".format(
|
||||||
"<strong>" + htmlentities(CHALLENGE_NAME) + "</strong>"
|
"<strong>" + htmlEntities(window.CHALLENGE_NAME) + "</strong>"
|
||||||
),
|
),
|
||||||
success: function() {
|
success: function() {
|
||||||
CTFd.fetch("/api/v1/challenges/" + CHALLENGE_ID, {
|
CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID, {
|
||||||
method: "DELETE"
|
method: "DELETE"
|
||||||
}).then(function(response) {
|
}).then(function(response) {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
@@ -203,7 +184,7 @@ $(() => {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const params = $(event.target).serializeJSON(true);
|
const params = $(event.target).serializeJSON(true);
|
||||||
|
|
||||||
CTFd.fetch("/api/v1/challenges/" + CHALLENGE_ID, {
|
CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -7,17 +7,17 @@ export function addFile(event) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
let form = event.target;
|
let form = event.target;
|
||||||
let data = {
|
let data = {
|
||||||
challenge: CHALLENGE_ID,
|
challenge: window.CHALLENGE_ID,
|
||||||
type: "challenge"
|
type: "challenge"
|
||||||
};
|
};
|
||||||
helpers.files.upload(form, data, function(response) {
|
helpers.files.upload(form, data, function(_response) {
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}, 700);
|
}, 700);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteFile(event) {
|
export function deleteFile(_event) {
|
||||||
const file_id = $(this).attr("file-id");
|
const file_id = $(this).attr("file-id");
|
||||||
const row = $(this)
|
const row = $(this)
|
||||||
.parent()
|
.parent()
|
||||||
|
|||||||
@@ -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) {
|
$.get(CTFd.config.urlRoot + "/api/v1/flags/types", function(response) {
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
const flag_type_select = $("#flags-create-select");
|
const flag_type_select = $("#flags-create-select");
|
||||||
@@ -52,7 +52,7 @@ export function addFlagModal(event) {
|
|||||||
$("#flag-edit-modal form").submit(function(event) {
|
$("#flag-edit-modal form").submit(function(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const params = $(this).serializeJSON(true);
|
const params = $(this).serializeJSON(true);
|
||||||
params["challenge"] = CHALLENGE_ID;
|
params["challenge"] = window.CHALLENGE_ID;
|
||||||
CTFd.fetch("/api/v1/flags", {
|
CTFd.fetch("/api/v1/flags", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
@@ -65,7 +65,7 @@ export function addFlagModal(event) {
|
|||||||
.then(function(response) {
|
.then(function(response) {
|
||||||
return response.json();
|
return response.json();
|
||||||
})
|
})
|
||||||
.then(function(response) {
|
.then(function(_response) {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,50 +1,18 @@
|
|||||||
import $ from "jquery";
|
import $ from "jquery";
|
||||||
import CTFd from "core/CTFd";
|
import CTFd from "core/CTFd";
|
||||||
import { ezQuery, ezAlert } from "core/ezq";
|
import { ezQuery } 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"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function showHintModal(event) {
|
export function showHintModal(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
$("#hint-edit-modal form")
|
$("#hint-edit-modal form")
|
||||||
.find("input, textarea")
|
.find("input, textarea")
|
||||||
.val("");
|
.val("")
|
||||||
|
// Trigger a change on the textarea to get codemirror to clone changes in
|
||||||
|
.trigger("change");
|
||||||
|
|
||||||
// Markdown Preview
|
$("#hint-edit-form textarea").each(function(i, e) {
|
||||||
$("#new-hint-edit").on("shown.bs.tab", function(event) {
|
if (e.hasOwnProperty("codemirror")) {
|
||||||
if (event.target.hash == "#hint-preview") {
|
e.codemirror.refresh();
|
||||||
const renderer = CTFd.lib.markdown();
|
|
||||||
const editor_value = $("#hint-write textarea").val();
|
|
||||||
$(event.target.hash).html(renderer.render(editor_value));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,21 +36,33 @@ export function showEditHintModal(event) {
|
|||||||
})
|
})
|
||||||
.then(function(response) {
|
.then(function(response) {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
$("#hint-edit-form input[name=content],textarea[name=content]").val(
|
$("#hint-edit-form input[name=content],textarea[name=content]")
|
||||||
response.data.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=cost]").val(response.data.cost);
|
||||||
$("#hint-edit-form input[name=id]").val(response.data.id);
|
$("#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();
|
$("#hint-edit-modal").modal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -116,7 +96,7 @@ export function deleteHint(event) {
|
|||||||
export function editHint(event) {
|
export function editHint(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const params = $(this).serializeJSON(true);
|
const params = $(this).serializeJSON(true);
|
||||||
params["challenge"] = CHALLENGE_ID;
|
params["challenge"] = window.CHALLENGE_ID;
|
||||||
|
|
||||||
let method = "POST";
|
let method = "POST";
|
||||||
let url = "/api/v1/hints";
|
let url = "/api/v1/hints";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import CTFd from "core/CTFd";
|
import CTFd from "core/CTFd";
|
||||||
|
import nunjucks from "nunjucks";
|
||||||
import $ from "jquery";
|
import $ from "jquery";
|
||||||
|
|
||||||
window.challenge = new Object();
|
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)
|
const challenge = $(this)
|
||||||
.find("option:selected")
|
.find("option:selected")
|
||||||
.data("meta");
|
.data("meta");
|
||||||
|
|||||||
@@ -10,15 +10,15 @@ export function addRequirement(event) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
CHALLENGE_REQUIREMENTS.prerequisites.push(
|
window.CHALLENGE_REQUIREMENTS.prerequisites.push(
|
||||||
parseInt(requirements["prerequisite"])
|
parseInt(requirements["prerequisite"])
|
||||||
);
|
);
|
||||||
|
|
||||||
const params = {
|
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",
|
method: "PATCH",
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
headers: {
|
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 challenge_id = $(this).attr("challenge-id");
|
||||||
const row = $(this)
|
const row = $(this)
|
||||||
.parent()
|
.parent()
|
||||||
.parent();
|
.parent();
|
||||||
|
|
||||||
CHALLENGE_REQUIREMENTS.prerequisites.pop(challenge_id);
|
window.CHALLENGE_REQUIREMENTS.prerequisites.pop(challenge_id);
|
||||||
|
|
||||||
const params = {
|
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",
|
method: "PATCH",
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import $ from "jquery";
|
import $ from "jquery";
|
||||||
import CTFd from "core/CTFd";
|
import CTFd from "core/CTFd";
|
||||||
|
|
||||||
export function deleteTag(event) {
|
export function deleteTag(_event) {
|
||||||
const $elem = $(this);
|
const $elem = $(this);
|
||||||
const tag_id = $elem.attr("tag-id");
|
const tag_id = $elem.attr("tag-id");
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ export function addTag(event) {
|
|||||||
const tag = $elem.val();
|
const tag = $elem.val();
|
||||||
const params = {
|
const params = {
|
||||||
value: tag,
|
value: tag,
|
||||||
challenge: CHALLENGE_ID
|
challenge: window.CHALLENGE_ID
|
||||||
};
|
};
|
||||||
|
|
||||||
CTFd.api.post_tag_list({}, params).then(response => {
|
CTFd.api.post_tag_list({}, params).then(response => {
|
||||||
|
|||||||
332
CTFd/themes/admin/assets/js/components/files/MediaLibrary.vue
Normal file
332
CTFd/themes/admin/assets/js/components/files/MediaLibrary.vue
Normal 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">×</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>
|
||||||
@@ -5,11 +5,11 @@ import "bootstrap/js/dist/tab";
|
|||||||
import CTFd from "core/CTFd";
|
import CTFd from "core/CTFd";
|
||||||
import { htmlEntities } from "core/utils";
|
import { htmlEntities } from "core/utils";
|
||||||
import { ezQuery, ezAlert, ezToast } from "core/ezq";
|
import { ezQuery, ezAlert, ezToast } from "core/ezq";
|
||||||
import nunjucks from "nunjucks";
|
|
||||||
import { default as helpers } from "core/helpers";
|
import { default as helpers } from "core/helpers";
|
||||||
import { addFile, deleteFile } from "../challenges/files";
|
import { addFile, deleteFile } from "../challenges/files";
|
||||||
import { addTag, deleteTag } from "../challenges/tags";
|
import { addTag, deleteTag } from "../challenges/tags";
|
||||||
import { addRequirement, deleteRequirement } from "../challenges/requirements";
|
import { addRequirement, deleteRequirement } from "../challenges/requirements";
|
||||||
|
import { bindMarkdownEditors } from "../styles";
|
||||||
import {
|
import {
|
||||||
showHintModal,
|
showHintModal,
|
||||||
editHint,
|
editHint,
|
||||||
@@ -39,7 +39,7 @@ const loadHint = id => {
|
|||||||
displayHint(response.data);
|
displayHint(response.data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
displayUnlock(id);
|
// displayUnlock(id);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -132,42 +132,34 @@ function renderSubmissionResponse(response, cb) {
|
|||||||
function loadChalTemplate(challenge) {
|
function loadChalTemplate(challenge) {
|
||||||
CTFd._internal.challenge = {};
|
CTFd._internal.challenge = {};
|
||||||
$.getScript(CTFd.config.urlRoot + challenge.scripts.view, function() {
|
$.getScript(CTFd.config.urlRoot + challenge.scripts.view, function() {
|
||||||
$.get(CTFd.config.urlRoot + challenge.templates.create, function(
|
let template_data = challenge.create;
|
||||||
template_data
|
$("#create-chal-entry-div").html(template_data);
|
||||||
) {
|
bindMarkdownEditors();
|
||||||
const template = nunjucks.compile(template_data);
|
|
||||||
$("#create-chal-entry-div").html(
|
|
||||||
template.render({
|
|
||||||
nonce: CTFd.config.csrfNonce,
|
|
||||||
script_root: CTFd.config.urlRoot
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
$.getScript(CTFd.config.urlRoot + challenge.scripts.create, function() {
|
$.getScript(CTFd.config.urlRoot + challenge.scripts.create, function() {
|
||||||
$("#create-chal-entry-div form").submit(function(event) {
|
$("#create-chal-entry-div form").submit(function(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const params = $("#create-chal-entry-div form").serializeJSON();
|
const params = $("#create-chal-entry-div form").serializeJSON();
|
||||||
CTFd.fetch("/api/v1/challenges", {
|
CTFd.fetch("/api/v1/challenges", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
},
|
},
|
||||||
body: JSON.stringify(params)
|
body: JSON.stringify(params)
|
||||||
|
})
|
||||||
|
.then(function(response) {
|
||||||
|
return response.json();
|
||||||
})
|
})
|
||||||
.then(function(response) {
|
.then(function(response) {
|
||||||
return response.json();
|
if (response.success) {
|
||||||
})
|
$("#challenge-create-options #challenge_id").val(
|
||||||
.then(function(response) {
|
response.data.id
|
||||||
if (response.success) {
|
);
|
||||||
$("#challenge-create-options #challenge_id").val(
|
$("#challenge-create-options").modal();
|
||||||
response.data.id
|
}
|
||||||
);
|
});
|
||||||
$("#challenge-create-options").modal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -210,7 +202,7 @@ function handleChallengeOptions(event) {
|
|||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
// Save flag
|
// Save flag
|
||||||
new Promise(function(resolve, reject) {
|
new Promise(function(resolve, _reject) {
|
||||||
if (flag_params.content.length == 0) {
|
if (flag_params.content.length == 0) {
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
return;
|
||||||
@@ -228,7 +220,7 @@ function handleChallengeOptions(event) {
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
// Upload files
|
// Upload files
|
||||||
new Promise(function(resolve, reject) {
|
new Promise(function(resolve, _reject) {
|
||||||
let form = event.target;
|
let form = event.target;
|
||||||
let data = {
|
let data = {
|
||||||
challenge: params.challenge_id,
|
challenge: params.challenge_id,
|
||||||
@@ -240,12 +232,12 @@ function handleChallengeOptions(event) {
|
|||||||
}
|
}
|
||||||
resolve();
|
resolve();
|
||||||
})
|
})
|
||||||
]).then(responses => {
|
]).then(_responses => {
|
||||||
save_challenge();
|
save_challenge();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createChallenge(event) {
|
function createChallenge(_event) {
|
||||||
const challenge = $(this)
|
const challenge = $(this)
|
||||||
.find("option:selected")
|
.find("option:selected")
|
||||||
.data("meta");
|
.data("meta");
|
||||||
@@ -257,113 +249,84 @@ function createChallenge(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$(() => {
|
$(() => {
|
||||||
$(".preview-challenge").click(function(e) {
|
$(".preview-challenge").click(function(_e) {
|
||||||
window.challenge = new Object();
|
window.challenge = new Object();
|
||||||
CTFd._internal.challenge = {};
|
CTFd._internal.challenge = {};
|
||||||
$.get(CTFd.config.urlRoot + "/api/v1/challenges/" + CHALLENGE_ID, function(
|
$.get(
|
||||||
response
|
CTFd.config.urlRoot + "/api/v1/challenges/" + window.CHALLENGE_ID,
|
||||||
) {
|
function(response) {
|
||||||
const challenge = CTFd._internal.challenge;
|
const challenge = CTFd._internal.challenge;
|
||||||
var challenge_data = response.data;
|
var challenge_data = response.data;
|
||||||
challenge_data["solves"] = null;
|
challenge_data["solves"] = null;
|
||||||
|
|
||||||
$.getScript(
|
$.getScript(
|
||||||
CTFd.config.urlRoot + challenge_data.type_data.scripts.view,
|
CTFd.config.urlRoot + challenge_data.type_data.scripts.view,
|
||||||
function() {
|
function() {
|
||||||
$.get(
|
$("#challenge-window").empty();
|
||||||
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();
|
|
||||||
|
|
||||||
challenge_data["description"] = challenge.render(
|
$("#challenge-window").append(challenge_data.view);
|
||||||
challenge_data["description"]
|
|
||||||
);
|
|
||||||
challenge_data["script_root"] = CTFd.config.urlRoot;
|
|
||||||
|
|
||||||
$("#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) {
|
$(".challenge-solves").hide();
|
||||||
getsolves($("#challenge-id").val());
|
$(".nav-tabs a").click(function(e) {
|
||||||
});
|
e.preventDefault();
|
||||||
$(".nav-tabs a").click(function(e) {
|
$(this).tab("show");
|
||||||
e.preventDefault();
|
});
|
||||||
$(this).tab("show");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle modal toggling
|
// Handle modal toggling
|
||||||
$("#challenge-window").on("hide.bs.modal", function(event) {
|
$("#challenge-window").on("hide.bs.modal", function(_event) {
|
||||||
$("#submission-input").removeClass("wrong");
|
$("#challenge-input").removeClass("wrong");
|
||||||
$("#submission-input").removeClass("correct");
|
$("#challenge-input").removeClass("correct");
|
||||||
$("#incorrect-key").slideUp();
|
$("#incorrect-key").slideUp();
|
||||||
$("#correct-key").slideUp();
|
$("#correct-key").slideUp();
|
||||||
$("#already-solved").slideUp();
|
$("#already-solved").slideUp();
|
||||||
$("#too-fast").slideUp();
|
$("#too-fast").slideUp();
|
||||||
});
|
});
|
||||||
|
|
||||||
$(".load-hint").on("click", function(event) {
|
$(".load-hint").on("click", function(_event) {
|
||||||
loadHint($(this).data("hint-id"));
|
loadHint($(this).data("hint-id"));
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#submit-key").click(function(e) {
|
$("#challenge-submit").click(function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
$("#submit-key").addClass("disabled-button");
|
$("#challenge-submit").addClass("disabled-button");
|
||||||
$("#submit-key").prop("disabled", true);
|
$("#challenge-submit").prop("disabled", true);
|
||||||
CTFd._internal.challenge
|
CTFd._internal.challenge
|
||||||
.submit(true)
|
.submit(true)
|
||||||
.then(renderSubmissionResponse);
|
.then(renderSubmissionResponse);
|
||||||
// Preview passed as true
|
// Preview passed as true
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#submission-input").keyup(function(event) {
|
$("#challenge-input").keyup(function(event) {
|
||||||
if (event.keyCode == 13) {
|
if (event.keyCode == 13) {
|
||||||
$("#submit-key").click();
|
$("#challenge-submit").click();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$(".input-field").bind({
|
challenge.postRender();
|
||||||
focus: function() {
|
window.location.replace(
|
||||||
$(this)
|
window.location.href.split("#")[0] + "#preview"
|
||||||
.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();
|
$("#challenge-window").modal();
|
||||||
window.location.replace(
|
}
|
||||||
window.location.href.split("#")[0] + "#preview"
|
);
|
||||||
);
|
}
|
||||||
|
);
|
||||||
$("#challenge-window").modal();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$(".delete-challenge").click(function(e) {
|
$(".delete-challenge").click(function(_e) {
|
||||||
ezQuery({
|
ezQuery({
|
||||||
title: "Delete Challenge",
|
title: "Delete Challenge",
|
||||||
body: "Are you sure you want to delete {0}".format(
|
body: "Are you sure you want to delete {0}".format(
|
||||||
"<strong>" + htmlEntities(CHALLENGE_NAME) + "</strong>"
|
"<strong>" + htmlEntities(window.CHALLENGE_NAME) + "</strong>"
|
||||||
),
|
),
|
||||||
success: function() {
|
success: function() {
|
||||||
CTFd.fetch("/api/v1/challenges/" + CHALLENGE_ID, {
|
CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID, {
|
||||||
method: "DELETE"
|
method: "DELETE"
|
||||||
})
|
})
|
||||||
.then(function(response) {
|
.then(function(response) {
|
||||||
@@ -382,7 +345,7 @@ $(() => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var params = $(e.target).serializeJSON(true);
|
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",
|
method: "GET",
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -395,7 +358,7 @@ $(() => {
|
|||||||
})
|
})
|
||||||
.then(function(response) {
|
.then(function(response) {
|
||||||
let update_challenge = function() {
|
let update_challenge = function() {
|
||||||
CTFd.fetch("/api/v1/challenges/" + CHALLENGE_ID, {
|
CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import CTFd from "core/CTFd";
|
|||||||
import $ from "jquery";
|
import $ from "jquery";
|
||||||
import { ezAlert, ezQuery } from "core/ezq";
|
import { ezAlert, ezQuery } from "core/ezq";
|
||||||
|
|
||||||
function deleteSelectedChallenges(event) {
|
function deleteSelectedChallenges(_event) {
|
||||||
let challengeIDs = $("input[data-challenge-id]:checked").map(function() {
|
let challengeIDs = $("input[data-challenge-id]:checked").map(function() {
|
||||||
return $(this).data("challenge-id");
|
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();
|
window.location.reload();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function bulkEditChallenges(event) {
|
function bulkEditChallenges(_event) {
|
||||||
let challengeIDs = $("input[data-challenge-id]:checked").map(function() {
|
let challengeIDs = $("input[data-challenge-id]:checked").map(function() {
|
||||||
return $(this).data("challenge-id");
|
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();
|
window.location.reload();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import moment from "moment-timezone";
|
|||||||
import CTFd from "core/CTFd";
|
import CTFd from "core/CTFd";
|
||||||
import { default as helpers } from "core/helpers";
|
import { default as helpers } from "core/helpers";
|
||||||
import $ from "jquery";
|
import $ from "jquery";
|
||||||
import { ezQuery, ezProgressBar } from "core/ezq";
|
import { ezQuery, ezProgressBar, ezAlert } from "core/ezq";
|
||||||
import CodeMirror from "codemirror";
|
import CodeMirror from "codemirror";
|
||||||
import "codemirror/mode/htmlmixed/htmlmixed.js";
|
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();
|
window.location.reload();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -154,7 +154,7 @@ function removeLogo() {
|
|||||||
};
|
};
|
||||||
CTFd.api
|
CTFd.api
|
||||||
.patch_config({ configKey: "ctf_logo" }, params)
|
.patch_config({ configKey: "ctf_logo" }, params)
|
||||||
.then(response => {
|
.then(_response => {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -182,7 +182,6 @@ function importConfig(event) {
|
|||||||
contentType: false,
|
contentType: false,
|
||||||
statusCode: {
|
statusCode: {
|
||||||
500: function(resp) {
|
500: function(resp) {
|
||||||
console.log(resp.responseText);
|
|
||||||
alert(resp.responseText);
|
alert(resp.responseText);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -199,7 +198,7 @@ function importConfig(event) {
|
|||||||
};
|
};
|
||||||
return xhr;
|
return xhr;
|
||||||
},
|
},
|
||||||
success: function(data) {
|
success: function(_data) {
|
||||||
pg = ezProgressBar({
|
pg = ezProgressBar({
|
||||||
target: pg,
|
target: pg,
|
||||||
width: 100
|
width: 100
|
||||||
@@ -216,7 +215,6 @@ function importConfig(event) {
|
|||||||
|
|
||||||
function exportConfig(event) {
|
function exportConfig(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const href = CTFd.config.urlRoot + "/admin/export";
|
|
||||||
window.location.href = $(this).attr("href");
|
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($("#start-timezone"));
|
||||||
insertTimezones($("#end-timezone"));
|
insertTimezones($("#end-timezone"));
|
||||||
insertTimezones($("#freeze-timezone"));
|
insertTimezones($("#freeze-timezone"));
|
||||||
|
|||||||
@@ -1,155 +1,11 @@
|
|||||||
import "./main";
|
import "./main";
|
||||||
|
import { showMediaLibrary } from "../styles";
|
||||||
import "core/utils";
|
import "core/utils";
|
||||||
import $ from "jquery";
|
import $ from "jquery";
|
||||||
import CTFd from "core/CTFd";
|
import CTFd from "core/CTFd";
|
||||||
import { default as helpers } from "core/helpers";
|
|
||||||
import CodeMirror from "codemirror";
|
import CodeMirror from "codemirror";
|
||||||
import "codemirror/mode/htmlmixed/htmlmixed.js";
|
import "codemirror/mode/htmlmixed/htmlmixed.js";
|
||||||
import { ezQuery, ezToast } from "core/ezq";
|
import { 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
function submit_form() {
|
function submit_form() {
|
||||||
// Save the CodeMirror data to the Textarea
|
// Save the CodeMirror data to the Textarea
|
||||||
@@ -158,8 +14,9 @@ function submit_form() {
|
|||||||
var target = "/api/v1/pages";
|
var target = "/api/v1/pages";
|
||||||
var method = "POST";
|
var method = "POST";
|
||||||
|
|
||||||
if (params.id) {
|
let part = window.location.pathname.split("/").pop();
|
||||||
target += "/" + params.id;
|
if (part !== "new") {
|
||||||
|
target += "/" + part;
|
||||||
method = "PATCH";
|
method = "PATCH";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,18 +46,12 @@ function submit_form() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function preview_page() {
|
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("action", CTFd.config.urlRoot + "/admin/pages/preview");
|
||||||
$("#page-edit").attr("target", "_blank");
|
$("#page-edit").attr("target", "_blank");
|
||||||
$("#page-edit").submit();
|
$("#page-edit").submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function upload_media() {
|
|
||||||
helpers.files.upload($("#media-library-upload"), {}, function(data) {
|
|
||||||
refresh_files();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$(() => {
|
$(() => {
|
||||||
window.editor = CodeMirror.fromTextArea(
|
window.editor = CodeMirror.fromTextArea(
|
||||||
document.getElementById("admin-pages-editor"),
|
document.getElementById("admin-pages-editor"),
|
||||||
@@ -212,55 +63,8 @@ $(() => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
$("#media-insert").click(function(e) {
|
$("#media-button").click(function(_e) {
|
||||||
var tag = "";
|
showMediaLibrary(window.editor);
|
||||||
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 = "".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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#save-page").click(function(e) {
|
$("#save-page").click(function(e) {
|
||||||
@@ -268,17 +72,6 @@ $(() => {
|
|||||||
submit_form();
|
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").click(function() {
|
||||||
preview_page();
|
preview_page();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import CTFd from "core/CTFd";
|
|||||||
import $ from "jquery";
|
import $ from "jquery";
|
||||||
import { ezQuery } from "core/ezq";
|
import { ezQuery } from "core/ezq";
|
||||||
|
|
||||||
function deleteSelectedUsers(event) {
|
function deleteSelectedUsers(_event) {
|
||||||
let pageIDs = $("input[data-page-id]:checked").map(function() {
|
let pageIDs = $("input[data-page-id]:checked").map(function() {
|
||||||
return $(this).data("page-id");
|
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();
|
window.location.reload();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import "./main";
|
import "./main";
|
||||||
import CTFd from "core/CTFd";
|
import CTFd from "core/CTFd";
|
||||||
import $ from "jquery";
|
import $ from "jquery";
|
||||||
import { ezAlert, ezQuery } from "core/ezq";
|
import { ezAlert } from "core/ezq";
|
||||||
|
|
||||||
const api_func = {
|
const api_func = {
|
||||||
users: (x, y) => CTFd.api.patch_user_public({ userId: x }, y),
|
users: (x, y) => CTFd.api.patch_user_public({ userId: x }, y),
|
||||||
@@ -46,12 +46,12 @@ function toggleSelectedAccounts(accountIDs, action) {
|
|||||||
for (var accId of accountIDs) {
|
for (var accId of accountIDs) {
|
||||||
reqs.push(api_func[CTFd.config.userMode](accId, params));
|
reqs.push(api_func[CTFd.config.userMode](accId, params));
|
||||||
}
|
}
|
||||||
Promise.all(reqs).then(responses => {
|
Promise.all(reqs).then(_responses => {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function bulkToggleAccounts(event) {
|
function bulkToggleAccounts(_event) {
|
||||||
let accountIDs = $("input[data-account-id]:checked").map(function() {
|
let accountIDs = $("input[data-account-id]:checked").map(function() {
|
||||||
return $(this).data("account-id");
|
return $(this).data("account-id");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,28 +2,16 @@ import "./main";
|
|||||||
import "core/utils";
|
import "core/utils";
|
||||||
import CTFd from "core/CTFd";
|
import CTFd from "core/CTFd";
|
||||||
import $ from "jquery";
|
import $ from "jquery";
|
||||||
import Plotly from "plotly.js-basic-dist";
|
import echarts from "echarts/dist/echarts-en.common";
|
||||||
import { createGraph, updateGraph } from "core/graphs";
|
import { colorHash } from "core/utils";
|
||||||
|
|
||||||
const graph_configs = {
|
const graph_configs = {
|
||||||
"#solves-graph": {
|
"#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(),
|
data: () => CTFd.api.get_challenge_solve_statistics(),
|
||||||
format: response => {
|
format: response => {
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
const chals = [];
|
const chals = [];
|
||||||
const counts = [];
|
const counts = [];
|
||||||
const annotations = [];
|
|
||||||
const solves = {};
|
const solves = {};
|
||||||
for (let c = 0; c < data.length; c++) {
|
for (let c = 0; c < data.length; c++) {
|
||||||
solves[data[c]["id"]] = {
|
solves[data[c]["id"]] = {
|
||||||
@@ -39,63 +27,155 @@ const graph_configs = {
|
|||||||
$.each(solves_order, function(key, value) {
|
$.each(solves_order, function(key, value) {
|
||||||
chals.push(solves[value].name);
|
chals.push(solves[value].name);
|
||||||
counts.push(solves[value].solves);
|
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 [
|
const option = {
|
||||||
{
|
title: {
|
||||||
type: "bar",
|
left: "center",
|
||||||
x: chals,
|
text: "Solve Counts"
|
||||||
y: counts,
|
|
||||||
text: counts,
|
|
||||||
orientation: "v"
|
|
||||||
},
|
},
|
||||||
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": {
|
"#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" }),
|
data: () => CTFd.api.get_submission_property_counts({ column: "type" }),
|
||||||
format: response => {
|
format: response => {
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
const solves = data["correct"];
|
const solves = data["correct"];
|
||||||
const fails = data["incorrect"];
|
const fails = data["incorrect"];
|
||||||
|
|
||||||
return [
|
let option = {
|
||||||
{
|
title: {
|
||||||
values: [solves, fails],
|
left: "center",
|
||||||
labels: ["Correct", "Incorrect"],
|
text: "Submission Percentages"
|
||||||
marker: {
|
|
||||||
colors: ["rgb(0, 209, 64)", "rgb(207, 38, 0)"]
|
|
||||||
},
|
|
||||||
text: ["Solves", "Fails"],
|
|
||||||
hole: 0.4,
|
|
||||||
type: "pie"
|
|
||||||
},
|
},
|
||||||
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": {
|
"#categories-pie-graph": {
|
||||||
layout: () => ({
|
|
||||||
title: "Category Breakdown"
|
|
||||||
}),
|
|
||||||
data: () => CTFd.api.get_challenge_property_counts({ column: "category" }),
|
data: () => CTFd.api.get_challenge_property_counts({ column: "category" }),
|
||||||
fn: () => "CTFd_categories_" + new Date().toISOString().slice(0, 19),
|
|
||||||
format: response => {
|
format: response => {
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
|
|
||||||
@@ -114,15 +194,84 @@ const graph_configs = {
|
|||||||
count.push(data[i].count);
|
count.push(data[i].count);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
let option = {
|
||||||
{
|
title: {
|
||||||
values: count,
|
left: "center",
|
||||||
labels: categories,
|
text: "Category Breakdown"
|
||||||
hole: 0.4,
|
|
||||||
type: "pie"
|
|
||||||
},
|
},
|
||||||
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
|
annotations: annotations
|
||||||
}),
|
}),
|
||||||
data: () => CTFd.api.get_challenge_solve_percentages(),
|
data: () => CTFd.api.get_challenge_solve_percentages(),
|
||||||
fn: () =>
|
|
||||||
"CTFd_challenge_percentages_" + new Date().toISOString().slice(0, 19),
|
|
||||||
format: response => {
|
format: response => {
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
|
|
||||||
@@ -167,15 +314,61 @@ const graph_configs = {
|
|||||||
annotations.push(result);
|
annotations.push(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
const option = {
|
||||||
{
|
title: {
|
||||||
type: "bar",
|
left: "center",
|
||||||
x: names,
|
text: "Solve Percentages per Challenge"
|
||||||
y: percents,
|
|
||||||
orientation: "v"
|
|
||||||
},
|
},
|
||||||
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();
|
return response.json();
|
||||||
}),
|
}),
|
||||||
fn: () =>
|
|
||||||
"CTFd_score_distribution_" + new Date().toISOString().slice(0, 19),
|
|
||||||
format: response => {
|
format: response => {
|
||||||
const data = response.data.brackets;
|
const data = response.data.brackets;
|
||||||
const keys = [];
|
const keys = [];
|
||||||
@@ -221,36 +412,73 @@ const graph_configs = {
|
|||||||
start = key;
|
start = key;
|
||||||
});
|
});
|
||||||
|
|
||||||
return [
|
const option = {
|
||||||
{
|
title: {
|
||||||
type: "bar",
|
left: "center",
|
||||||
x: brackets,
|
text: "Score Distribution"
|
||||||
y: sizes,
|
},
|
||||||
orientation: "v"
|
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 = () => {
|
const createGraphs = () => {
|
||||||
for (let key in graph_configs) {
|
for (let key in graph_configs) {
|
||||||
const cfg = graph_configs[key];
|
const cfg = graph_configs[key];
|
||||||
|
|
||||||
const $elem = $(key);
|
const $elem = $(key);
|
||||||
$elem.empty();
|
$elem.empty();
|
||||||
$elem[0].fn = cfg.fn();
|
|
||||||
|
let chart = echarts.init(document.querySelector(key));
|
||||||
|
|
||||||
cfg
|
cfg
|
||||||
.data()
|
.data()
|
||||||
.then(cfg.format)
|
.then(cfg.format)
|
||||||
.then(([data, annotations]) => {
|
.then(option => {
|
||||||
Plotly.newPlot($elem[0], [data], cfg.layout(annotations), config);
|
chart.setOption(option);
|
||||||
|
$(window).on("resize", function() {
|
||||||
|
if (chart != null && chart != undefined) {
|
||||||
|
chart.resize();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -258,13 +486,12 @@ const createGraphs = () => {
|
|||||||
function updateGraphs() {
|
function updateGraphs() {
|
||||||
for (let key in graph_configs) {
|
for (let key in graph_configs) {
|
||||||
const cfg = graph_configs[key];
|
const cfg = graph_configs[key];
|
||||||
const $elem = $(key);
|
let chart = echarts.init(document.querySelector(key));
|
||||||
cfg
|
cfg
|
||||||
.data()
|
.data()
|
||||||
.then(cfg.format)
|
.then(cfg.format)
|
||||||
.then(([data, annotations]) => {
|
.then(option => {
|
||||||
// FIXME: Pass annotations
|
chart.setOption(option);
|
||||||
Plotly.react($elem[0], [data], cfg.layout(annotations), config);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import $ from "jquery";
|
|||||||
import { htmlEntities } from "core/utils";
|
import { htmlEntities } from "core/utils";
|
||||||
import { ezQuery } from "core/ezq";
|
import { ezQuery } from "core/ezq";
|
||||||
|
|
||||||
function deleteCorrectSubmission(event) {
|
function deleteCorrectSubmission(_event) {
|
||||||
const key_id = $(this).data("submission-id");
|
const key_id = $(this).data("submission-id");
|
||||||
const $elem = $(this)
|
const $elem = $(this)
|
||||||
.parent()
|
.parent()
|
||||||
@@ -40,7 +40,7 @@ function deleteCorrectSubmission(event) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteSelectedSubmissions(event) {
|
function deleteSelectedSubmissions(_event) {
|
||||||
let submissionIDs = $("input[data-submission-id]:checked").map(function() {
|
let submissionIDs = $("input[data-submission-id]:checked").map(function() {
|
||||||
return $(this).data("submission-id");
|
return $(this).data("submission-id");
|
||||||
});
|
});
|
||||||
@@ -54,7 +54,7 @@ function deleteSelectedSubmissions(event) {
|
|||||||
for (var subId of submissionIDs) {
|
for (var subId of submissionIDs) {
|
||||||
reqs.push(CTFd.api.delete_submission({ submissionId: subId }));
|
reqs.push(CTFd.api.delete_submission({ submissionId: subId }));
|
||||||
}
|
}
|
||||||
Promise.all(reqs).then(responses => {
|
Promise.all(reqs).then(_responses => {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user