mirror of
https://github.com/aljazceru/CTFd.git
synced 2025-12-17 14:04:20 +01:00
Squashed 'CTFd/themes/core-beta/' changes from 9126d77d..5ce3003b
5ce3003b Merge pull request #47 from aCursedComrade/patch-1 c9887cb1 Fix team template git-subtree-dir: CTFd/themes/core-beta git-subtree-split: 5ce3003b4d68352e629ee2d390bc999e7d6b071e
This commit is contained in:
@@ -1,9 +0,0 @@
|
|||||||
coverage:
|
|
||||||
status:
|
|
||||||
project:
|
|
||||||
default:
|
|
||||||
# Fail the status if coverage drops by >= 1%
|
|
||||||
threshold: 1
|
|
||||||
patch:
|
|
||||||
default:
|
|
||||||
threshold: 1
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
CTFd/logs/*.log
|
|
||||||
CTFd/static/uploads
|
|
||||||
CTFd/uploads
|
|
||||||
CTFd/*.db
|
|
||||||
CTFd/uploads/**/*
|
|
||||||
.ctfd_secret_key
|
|
||||||
.data
|
|
||||||
.git
|
|
||||||
.codecov.yml
|
|
||||||
.dockerignore
|
|
||||||
.github
|
|
||||||
.gitignore
|
|
||||||
.prettierignore
|
|
||||||
.travis.yml
|
|
||||||
**/node_modules
|
|
||||||
**/*.pyc
|
|
||||||
**/__pycache__
|
|
||||||
.venv*
|
|
||||||
venv*
|
|
||||||
18
.eslintrc.js
18
.eslintrc.js
@@ -1,18 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
"env": {
|
|
||||||
"browser": true,
|
|
||||||
"es6": true
|
|
||||||
},
|
|
||||||
"extends": "eslint:recommended",
|
|
||||||
"globals": {
|
|
||||||
"Atomics": "readonly",
|
|
||||||
"SharedArrayBuffer": "readonly"
|
|
||||||
},
|
|
||||||
"parserOptions": {
|
|
||||||
"ecmaVersion": 2018,
|
|
||||||
"sourceType": "module"
|
|
||||||
},
|
|
||||||
"rules": {
|
|
||||||
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
19
.github/ISSUE_TEMPLATE.md
vendored
19
.github/ISSUE_TEMPLATE.md
vendored
@@ -1,19 +0,0 @@
|
|||||||
<!--
|
|
||||||
If this is a bug report please fill out the template below.
|
|
||||||
|
|
||||||
If this is a feature request please describe the behavior that you'd like to see.
|
|
||||||
-->
|
|
||||||
|
|
||||||
**Environment**:
|
|
||||||
|
|
||||||
- CTFd Version/Commit:
|
|
||||||
- Operating System:
|
|
||||||
- Web Browser and Version:
|
|
||||||
|
|
||||||
**What happened?**
|
|
||||||
|
|
||||||
**What did you expect to happen?**
|
|
||||||
|
|
||||||
**How to reproduce your issue**
|
|
||||||
|
|
||||||
**Any associated stack traces or error logs**
|
|
||||||
46
.github/workflows/docker-build.yml
vendored
46
.github/workflows/docker-build.yml
vendored
@@ -1,46 +0,0 @@
|
|||||||
name: Docker build image on release
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
docker:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
steps:
|
|
||||||
- name: Set repo name lowercase
|
|
||||||
id: repo
|
|
||||||
uses: ASzc/change-string-case-action@v2
|
|
||||||
with:
|
|
||||||
string: ${{ github.repository }}
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v2
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v2
|
|
||||||
- name: Login to DockerHub
|
|
||||||
uses: docker/login-action@v2
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
- name: Login to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v2
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- name: Build and push
|
|
||||||
uses: docker/build-push-action@v3
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ steps.repo.outputs.lowercase }}:latest
|
|
||||||
ghcr.io/${{ steps.repo.outputs.lowercase }}:latest
|
|
||||||
${{ steps.repo.outputs.lowercase }}:${{ github.event.release.tag_name }}
|
|
||||||
ghcr.io/${{ steps.repo.outputs.lowercase }}:${{ github.event.release.tag_name }}
|
|
||||||
44
.github/workflows/lint.yml
vendored
44
.github/workflows/lint.yml
vendored
@@ -1,44 +0,0 @@
|
|||||||
---
|
|
||||||
name: Linting
|
|
||||||
|
|
||||||
on: [push, pull_request]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
python-version: ['3.9']
|
|
||||||
|
|
||||||
name: Linting
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- name: Setup python
|
|
||||||
uses: actions/setup-python@v2
|
|
||||||
with:
|
|
||||||
python-version: ${{ matrix.python-version }}
|
|
||||||
architecture: x64
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
python -m pip install -r development.txt
|
|
||||||
sudo yarn install --non-interactive
|
|
||||||
sudo yarn global add prettier@1.17.0
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: make lint
|
|
||||||
env:
|
|
||||||
TESTING_DATABASE_URL: 'sqlite://'
|
|
||||||
|
|
||||||
- name: Lint Dockerfile
|
|
||||||
uses: brpaz/hadolint-action@master
|
|
||||||
with:
|
|
||||||
dockerfile: "Dockerfile"
|
|
||||||
|
|
||||||
- name: Lint docker-compose
|
|
||||||
run: |
|
|
||||||
python -m pip install docker-compose==1.26.0
|
|
||||||
docker-compose -f docker-compose.yml config
|
|
||||||
|
|
||||||
53
.github/workflows/mysql.yml
vendored
53
.github/workflows/mysql.yml
vendored
@@ -1,53 +0,0 @@
|
|||||||
---
|
|
||||||
name: CTFd MySQL CI
|
|
||||||
|
|
||||||
on: [push, pull_request]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
services:
|
|
||||||
mysql:
|
|
||||||
image: mysql:5.7
|
|
||||||
env:
|
|
||||||
MYSQL_ROOT_PASSWORD: password
|
|
||||||
ports:
|
|
||||||
- 3306
|
|
||||||
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
|
||||||
redis:
|
|
||||||
image: redis
|
|
||||||
ports:
|
|
||||||
- 6379:6379
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
python-version: ['3.9']
|
|
||||||
|
|
||||||
name: Python ${{ matrix.python-version }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- name: Setup python
|
|
||||||
uses: actions/setup-python@v2
|
|
||||||
with:
|
|
||||||
python-version: ${{ matrix.python-version }}
|
|
||||||
architecture: x64
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
python -m pip install -r development.txt
|
|
||||||
sudo yarn install --non-interactive
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: |
|
|
||||||
sudo rm -f /etc/boto.cfg
|
|
||||||
make test
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
|
|
||||||
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
|
||||||
TESTING_DATABASE_URL: mysql+pymysql://root:password@localhost:${{ job.services.mysql.ports[3306] }}/ctfd
|
|
||||||
|
|
||||||
- name: Codecov
|
|
||||||
uses: codecov/codecov-action@v1.0.11
|
|
||||||
with:
|
|
||||||
file: ./coverage.xml
|
|
||||||
61
.github/workflows/postgres.yml
vendored
61
.github/workflows/postgres.yml
vendored
@@ -1,61 +0,0 @@
|
|||||||
---
|
|
||||||
name: CTFd Postgres CI
|
|
||||||
|
|
||||||
on: [push, pull_request]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres
|
|
||||||
ports:
|
|
||||||
- 5432:5432
|
|
||||||
env:
|
|
||||||
POSTGRES_HOST_AUTH_METHOD: trust
|
|
||||||
POSTGRES_DB: ctfd
|
|
||||||
POSTGRES_PASSWORD: password
|
|
||||||
# Set health checks to wait until postgres has started
|
|
||||||
options: >-
|
|
||||||
--health-cmd pg_isready
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 5
|
|
||||||
redis:
|
|
||||||
image: redis
|
|
||||||
ports:
|
|
||||||
- 6379:6379
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
python-version: ['3.9']
|
|
||||||
|
|
||||||
name: Python ${{ matrix.python-version }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- name: Setup python
|
|
||||||
uses: actions/setup-python@v2
|
|
||||||
with:
|
|
||||||
python-version: ${{ matrix.python-version }}
|
|
||||||
architecture: x64
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
python -m pip install -r development.txt
|
|
||||||
sudo yarn install --non-interactive
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: |
|
|
||||||
sudo rm -f /etc/boto.cfg
|
|
||||||
make test
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
|
|
||||||
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
|
||||||
TESTING_DATABASE_URL: postgres://postgres:password@localhost:${{ job.services.postgres.ports[5432] }}/ctfd
|
|
||||||
|
|
||||||
- name: Codecov
|
|
||||||
uses: codecov/codecov-action@v1.0.11
|
|
||||||
with:
|
|
||||||
file: ./coverage.xml
|
|
||||||
|
|
||||||
43
.github/workflows/sqlite.yml
vendored
43
.github/workflows/sqlite.yml
vendored
@@ -1,43 +0,0 @@
|
|||||||
---
|
|
||||||
name: CTFd SQLite CI
|
|
||||||
|
|
||||||
on: [push, pull_request]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
python-version: ['3.9']
|
|
||||||
|
|
||||||
name: Python ${{ matrix.python-version }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- name: Setup python
|
|
||||||
uses: actions/setup-python@v2
|
|
||||||
with:
|
|
||||||
python-version: ${{ matrix.python-version }}
|
|
||||||
architecture: x64
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
python -m pip install -r development.txt
|
|
||||||
sudo yarn install --non-interactive
|
|
||||||
sudo yarn global add prettier@1.17.0
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: |
|
|
||||||
sudo rm -f /etc/boto.cfg
|
|
||||||
make test
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
|
|
||||||
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
|
||||||
TESTING_DATABASE_URL: 'sqlite://'
|
|
||||||
|
|
||||||
- name: Codecov
|
|
||||||
uses: codecov/codecov-action@v1.0.11
|
|
||||||
with:
|
|
||||||
file: ./coverage.xml
|
|
||||||
|
|
||||||
97
.gitignore
vendored
97
.gitignore
vendored
@@ -1,81 +1,24 @@
|
|||||||
# Byte-compiled / optimized / DLL files
|
# Logs
|
||||||
__pycache__/
|
logs
|
||||||
*.py[cod]
|
|
||||||
|
|
||||||
# C extensions
|
|
||||||
*.so
|
|
||||||
|
|
||||||
# Distribution / packaging
|
|
||||||
.Python
|
|
||||||
env/
|
|
||||||
venv*
|
|
||||||
.venv*
|
|
||||||
build/
|
|
||||||
develop-eggs/
|
|
||||||
dist/
|
|
||||||
downloads/
|
|
||||||
eggs/
|
|
||||||
lib/
|
|
||||||
lib64/
|
|
||||||
parts/
|
|
||||||
sdist/
|
|
||||||
var/
|
|
||||||
*.egg-info/
|
|
||||||
.installed.cfg
|
|
||||||
*.egg
|
|
||||||
|
|
||||||
# PyInstaller
|
|
||||||
# Usually these files are written by a python script from a template
|
|
||||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
||||||
*.manifest
|
|
||||||
*.spec
|
|
||||||
|
|
||||||
# Installer logs
|
|
||||||
pip-log.txt
|
|
||||||
pip-delete-this-directory.txt
|
|
||||||
|
|
||||||
# Unit test / coverage reports
|
|
||||||
htmlcov/
|
|
||||||
.tox/
|
|
||||||
.coverage
|
|
||||||
.coverage.*
|
|
||||||
.cache
|
|
||||||
nosetests.xml
|
|
||||||
coverage.xml
|
|
||||||
|
|
||||||
# Translations
|
|
||||||
*.mo
|
|
||||||
|
|
||||||
# Django stuff:
|
|
||||||
*.log
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
# Sphinx documentation
|
node_modules
|
||||||
docs/_build/
|
dist
|
||||||
|
dist-ssr
|
||||||
# PyBuilder
|
*.local
|
||||||
target/
|
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
*.suo
|
||||||
*.db
|
*.ntvs*
|
||||||
*.log
|
*.njsproj
|
||||||
*.log.*
|
*.sln
|
||||||
.idea/
|
*.sw?
|
||||||
.vscode/
|
|
||||||
CTFd/static/uploads
|
|
||||||
CTFd/uploads
|
|
||||||
.data/
|
|
||||||
.ctfd_secret_key
|
|
||||||
.*.swp
|
|
||||||
|
|
||||||
# Vagrant
|
|
||||||
.vagrant
|
|
||||||
|
|
||||||
# CTFd Exports
|
|
||||||
*.zip
|
|
||||||
|
|
||||||
# JS
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Flask Profiler files
|
|
||||||
flask_profiler.sql
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
[settings]
|
|
||||||
multi_line_output=3
|
|
||||||
include_trailing_comma=True
|
|
||||||
force_grid_wrap=0
|
|
||||||
use_parentheses=True
|
|
||||||
line_length=88
|
|
||||||
skip=migrations
|
|
||||||
@@ -1,14 +1,2 @@
|
|||||||
CTFd/themes/**/vendor/
|
static
|
||||||
CTFd/themes/core-beta/**/*
|
**/*.html
|
||||||
*.html
|
|
||||||
*.njk
|
|
||||||
*.png
|
|
||||||
*.svg
|
|
||||||
*.ico
|
|
||||||
*.ai
|
|
||||||
*.svg
|
|
||||||
*.mp3
|
|
||||||
*.webm
|
|
||||||
.pytest_cache
|
|
||||||
venv*
|
|
||||||
.venv*
|
|
||||||
1968
CHANGELOG.md
1968
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
|||||||
## How to contribute to CTFd
|
|
||||||
|
|
||||||
#### **Did you find a bug?**
|
|
||||||
|
|
||||||
- **Do not open up a GitHub issue if the bug is a security vulnerability in CTFd**. Instead [email the details to us at support@ctfd.io](mailto:support@ctfd.io).
|
|
||||||
|
|
||||||
- **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/CTFd/CTFd/issues).
|
|
||||||
|
|
||||||
- If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/CTFd/CTFd/issues/new). Be sure to fill out the issue template with a **title and clear description**, and as much relevant information as possible (e.g. deployment setup, browser version, etc).
|
|
||||||
|
|
||||||
#### **Did you write a patch that fixes a bug or implements a new feature?**
|
|
||||||
|
|
||||||
- Open a new pull request with the patch.
|
|
||||||
|
|
||||||
- Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable.
|
|
||||||
|
|
||||||
- Ensure all status checks pass. PR's with test failures will not be merged. PR's with insufficient coverage may be merged depending on the situation.
|
|
||||||
|
|
||||||
#### **Did you fix whitespace, format code, or make a purely cosmetic patch?**
|
|
||||||
|
|
||||||
Changes that are cosmetic in nature and do not add anything substantial to the stability, functionality, or testability of CTFd will generally not be accepted.
|
|
||||||
313
CTFd/__init__.py
313
CTFd/__init__.py
@@ -1,313 +0,0 @@
|
|||||||
import datetime
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import weakref
|
|
||||||
from distutils.version import StrictVersion
|
|
||||||
|
|
||||||
import jinja2
|
|
||||||
from flask import Flask, Request
|
|
||||||
from flask.helpers import safe_join
|
|
||||||
from flask_babel import Babel
|
|
||||||
from flask_migrate import upgrade
|
|
||||||
from jinja2 import FileSystemLoader
|
|
||||||
from jinja2.sandbox import SandboxedEnvironment
|
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
|
||||||
from werkzeug.utils import cached_property
|
|
||||||
|
|
||||||
import CTFd.utils.config
|
|
||||||
from CTFd import utils
|
|
||||||
from CTFd.constants.themes import ADMIN_THEME, DEFAULT_THEME
|
|
||||||
from CTFd.plugins import init_plugins
|
|
||||||
from CTFd.utils.crypto import sha256
|
|
||||||
from CTFd.utils.initialization import (
|
|
||||||
init_events,
|
|
||||||
init_logs,
|
|
||||||
init_request_processors,
|
|
||||||
init_template_filters,
|
|
||||||
init_template_globals,
|
|
||||||
)
|
|
||||||
from CTFd.utils.migrations import create_database, migrations, stamp_latest_revision
|
|
||||||
from CTFd.utils.sessions import CachingSessionInterface
|
|
||||||
from CTFd.utils.updates import update_check
|
|
||||||
from CTFd.utils.user import get_locale
|
|
||||||
|
|
||||||
__version__ = "3.5.2"
|
|
||||||
__channel__ = "oss"
|
|
||||||
|
|
||||||
|
|
||||||
class CTFdRequest(Request):
|
|
||||||
@cached_property
|
|
||||||
def path(self):
|
|
||||||
"""
|
|
||||||
Hijack the original Flask request path because it does not account for subdirectory deployments in an intuitive
|
|
||||||
manner. We append script_root so that the path always points to the full path as seen in the browser.
|
|
||||||
e.g. /subdirectory/path/route vs /path/route
|
|
||||||
|
|
||||||
:return: string
|
|
||||||
"""
|
|
||||||
return self.script_root + super(CTFdRequest, self).path
|
|
||||||
|
|
||||||
|
|
||||||
class CTFdFlask(Flask):
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
"""Overriden Jinja constructor setting a custom jinja_environment"""
|
|
||||||
self.jinja_environment = SandboxedBaseEnvironment
|
|
||||||
self.session_interface = CachingSessionInterface(key_prefix="session")
|
|
||||||
self.request_class = CTFdRequest
|
|
||||||
|
|
||||||
# Store server start time
|
|
||||||
self.start_time = datetime.datetime.utcnow()
|
|
||||||
|
|
||||||
# Create generally unique run identifier
|
|
||||||
self.run_id = sha256(str(self.start_time))[0:8]
|
|
||||||
Flask.__init__(self, *args, **kwargs)
|
|
||||||
|
|
||||||
def create_jinja_environment(self):
|
|
||||||
"""Overridden jinja environment constructor"""
|
|
||||||
return super(CTFdFlask, self).create_jinja_environment()
|
|
||||||
|
|
||||||
|
|
||||||
class SandboxedBaseEnvironment(SandboxedEnvironment):
|
|
||||||
"""SandboxEnvironment that mimics the Flask BaseEnvironment"""
|
|
||||||
|
|
||||||
def __init__(self, app, **options):
|
|
||||||
if "loader" not in options:
|
|
||||||
options["loader"] = app.create_global_jinja_loader()
|
|
||||||
SandboxedEnvironment.__init__(self, **options)
|
|
||||||
self.app = app
|
|
||||||
|
|
||||||
def _load_template(self, name, globals):
|
|
||||||
if self.loader is None:
|
|
||||||
raise TypeError("no loader for this environment specified")
|
|
||||||
|
|
||||||
# Add theme to the LRUCache cache key
|
|
||||||
cache_name = name
|
|
||||||
if name.startswith("admin/") is False:
|
|
||||||
theme = str(utils.get_config("ctf_theme"))
|
|
||||||
cache_name = theme + "/" + name
|
|
||||||
|
|
||||||
# Rest of this code is copied from Jinja
|
|
||||||
# https://github.com/pallets/jinja/blob/master/src/jinja2/environment.py#L802-L815
|
|
||||||
cache_key = (weakref.ref(self.loader), cache_name)
|
|
||||||
if self.cache is not None:
|
|
||||||
template = self.cache.get(cache_key)
|
|
||||||
if template is not None and (
|
|
||||||
not self.auto_reload or template.is_up_to_date
|
|
||||||
):
|
|
||||||
return template
|
|
||||||
template = self.loader.load(self, name, globals)
|
|
||||||
if self.cache is not None:
|
|
||||||
self.cache[cache_key] = template
|
|
||||||
return template
|
|
||||||
|
|
||||||
|
|
||||||
class ThemeLoader(FileSystemLoader):
|
|
||||||
"""Custom FileSystemLoader that is aware of theme structure and config.
|
|
||||||
"""
|
|
||||||
|
|
||||||
DEFAULT_THEMES_PATH = os.path.join(os.path.dirname(__file__), "themes")
|
|
||||||
_ADMIN_THEME_PREFIX = ADMIN_THEME + "/"
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
searchpath=DEFAULT_THEMES_PATH,
|
|
||||||
theme_name=None,
|
|
||||||
encoding="utf-8",
|
|
||||||
followlinks=False,
|
|
||||||
):
|
|
||||||
super(ThemeLoader, self).__init__(searchpath, encoding, followlinks)
|
|
||||||
self.theme_name = theme_name
|
|
||||||
|
|
||||||
def get_source(self, environment, template):
|
|
||||||
# Refuse to load `admin/*` from a loader not for the admin theme
|
|
||||||
# Because there is a single template loader, themes can essentially
|
|
||||||
# provide files for other themes. This could end up causing issues if
|
|
||||||
# an admin theme references a file that doesn't exist that a malicious
|
|
||||||
# theme provides.
|
|
||||||
if template.startswith(self._ADMIN_THEME_PREFIX):
|
|
||||||
if self.theme_name != ADMIN_THEME:
|
|
||||||
raise jinja2.TemplateNotFound(template)
|
|
||||||
template = template[len(self._ADMIN_THEME_PREFIX) :]
|
|
||||||
theme_name = self.theme_name or str(utils.get_config("ctf_theme"))
|
|
||||||
template = safe_join(theme_name, "templates", template)
|
|
||||||
return super(ThemeLoader, self).get_source(environment, template)
|
|
||||||
|
|
||||||
|
|
||||||
def confirm_upgrade():
|
|
||||||
if sys.stdin.isatty():
|
|
||||||
print("/*\\ CTFd has updated and must update the database! /*\\")
|
|
||||||
print("/*\\ Please backup your database before proceeding! /*\\")
|
|
||||||
print("/*\\ CTFd maintainers are not responsible for any data loss! /*\\")
|
|
||||||
if input("Run database migrations (Y/N)").lower().strip() == "y": # nosec B322
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print("/*\\ Ignored database migrations... /*\\")
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def run_upgrade():
|
|
||||||
upgrade()
|
|
||||||
utils.set_config("ctf_version", __version__)
|
|
||||||
|
|
||||||
|
|
||||||
def create_app(config="CTFd.config.Config"):
|
|
||||||
app = CTFdFlask(__name__)
|
|
||||||
with app.app_context():
|
|
||||||
app.config.from_object(config)
|
|
||||||
|
|
||||||
loaders = []
|
|
||||||
# We provide a `DictLoader` which may be used to override templates
|
|
||||||
app.overridden_templates = {}
|
|
||||||
loaders.append(jinja2.DictLoader(app.overridden_templates))
|
|
||||||
# A `ThemeLoader` with no `theme_name` will load from the current theme
|
|
||||||
loaders.append(ThemeLoader())
|
|
||||||
# If `THEME_FALLBACK` is set and true, we add another loader which will
|
|
||||||
# load from the `DEFAULT_THEME` - this mirrors the order implemented by
|
|
||||||
# `config.ctf_theme_candidates()`
|
|
||||||
if bool(app.config.get("THEME_FALLBACK")):
|
|
||||||
loaders.append(ThemeLoader(theme_name=DEFAULT_THEME))
|
|
||||||
# All themes including admin can be accessed by prefixing their name
|
|
||||||
prefix_loader_dict = {ADMIN_THEME: ThemeLoader(theme_name=ADMIN_THEME)}
|
|
||||||
for theme_name in CTFd.utils.config.get_themes():
|
|
||||||
prefix_loader_dict[theme_name] = ThemeLoader(theme_name=theme_name)
|
|
||||||
loaders.append(jinja2.PrefixLoader(prefix_loader_dict))
|
|
||||||
# Plugin templates are also accessed via prefix but we just point a
|
|
||||||
# normal `FileSystemLoader` at the plugin tree rather than validating
|
|
||||||
# each plugin here (that happens later in `init_plugins()`). We
|
|
||||||
# deliberately don't add this to `prefix_loader_dict` defined above
|
|
||||||
# because to do so would break template loading from a theme called
|
|
||||||
# `prefix` (even though that'd be weird).
|
|
||||||
plugin_loader = jinja2.FileSystemLoader(
|
|
||||||
searchpath=os.path.join(app.root_path, "plugins"), followlinks=True
|
|
||||||
)
|
|
||||||
loaders.append(jinja2.PrefixLoader({"plugins": plugin_loader}))
|
|
||||||
# Use a choice loader to find the first match from our list of loaders
|
|
||||||
app.jinja_loader = jinja2.ChoiceLoader(loaders)
|
|
||||||
|
|
||||||
from CTFd.models import ( # noqa: F401
|
|
||||||
Challenges,
|
|
||||||
Fails,
|
|
||||||
Files,
|
|
||||||
Flags,
|
|
||||||
Solves,
|
|
||||||
Tags,
|
|
||||||
Teams,
|
|
||||||
Tracking,
|
|
||||||
db,
|
|
||||||
)
|
|
||||||
|
|
||||||
url = create_database()
|
|
||||||
|
|
||||||
# This allows any changes to the SQLALCHEMY_DATABASE_URI to get pushed back in
|
|
||||||
# This is mostly so we can force MySQL's charset
|
|
||||||
app.config["SQLALCHEMY_DATABASE_URI"] = str(url)
|
|
||||||
|
|
||||||
# Register database
|
|
||||||
db.init_app(app)
|
|
||||||
|
|
||||||
# Register Flask-Migrate
|
|
||||||
migrations.init_app(app, db)
|
|
||||||
|
|
||||||
babel = Babel()
|
|
||||||
babel.locale_selector_func = get_locale
|
|
||||||
babel.init_app(app)
|
|
||||||
|
|
||||||
# Alembic sqlite support is lacking so we should just create_all anyway
|
|
||||||
if url.drivername.startswith("sqlite"):
|
|
||||||
# Enable foreign keys for SQLite. This must be before the
|
|
||||||
# db.create_all call because tests use the in-memory SQLite
|
|
||||||
# database (each connection, including db creation, is a new db).
|
|
||||||
# https://docs.sqlalchemy.org/en/13/dialects/sqlite.html#foreign-key-support
|
|
||||||
from sqlalchemy import event
|
|
||||||
from sqlalchemy.engine import Engine
|
|
||||||
|
|
||||||
@event.listens_for(Engine, "connect")
|
|
||||||
def set_sqlite_pragma(dbapi_connection, connection_record):
|
|
||||||
cursor = dbapi_connection.cursor()
|
|
||||||
cursor.execute("PRAGMA foreign_keys=ON")
|
|
||||||
cursor.close()
|
|
||||||
|
|
||||||
db.create_all()
|
|
||||||
stamp_latest_revision()
|
|
||||||
else:
|
|
||||||
# This creates tables instead of db.create_all()
|
|
||||||
# Allows migrations to happen properly
|
|
||||||
upgrade()
|
|
||||||
|
|
||||||
from CTFd.models import ma
|
|
||||||
|
|
||||||
ma.init_app(app)
|
|
||||||
|
|
||||||
app.db = db
|
|
||||||
app.VERSION = __version__
|
|
||||||
app.CHANNEL = __channel__
|
|
||||||
|
|
||||||
from CTFd.cache import cache
|
|
||||||
|
|
||||||
cache.init_app(app)
|
|
||||||
app.cache = cache
|
|
||||||
|
|
||||||
reverse_proxy = app.config.get("REVERSE_PROXY")
|
|
||||||
if reverse_proxy:
|
|
||||||
if type(reverse_proxy) is str and "," in reverse_proxy:
|
|
||||||
proxyfix_args = [int(i) for i in reverse_proxy.split(",")]
|
|
||||||
app.wsgi_app = ProxyFix(app.wsgi_app, *proxyfix_args)
|
|
||||||
else:
|
|
||||||
app.wsgi_app = ProxyFix(
|
|
||||||
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1
|
|
||||||
)
|
|
||||||
|
|
||||||
version = utils.get_config("ctf_version")
|
|
||||||
|
|
||||||
# Upgrading from an older version of CTFd
|
|
||||||
if version and (StrictVersion(version) < StrictVersion(__version__)):
|
|
||||||
if confirm_upgrade():
|
|
||||||
run_upgrade()
|
|
||||||
else:
|
|
||||||
exit()
|
|
||||||
|
|
||||||
if not version:
|
|
||||||
utils.set_config("ctf_version", __version__)
|
|
||||||
|
|
||||||
if not utils.get_config("ctf_theme"):
|
|
||||||
utils.set_config("ctf_theme", DEFAULT_THEME)
|
|
||||||
|
|
||||||
update_check(force=True)
|
|
||||||
|
|
||||||
init_request_processors(app)
|
|
||||||
init_template_filters(app)
|
|
||||||
init_template_globals(app)
|
|
||||||
|
|
||||||
# Importing here allows tests to use sensible names (e.g. api instead of api_bp)
|
|
||||||
from CTFd.admin import admin
|
|
||||||
from CTFd.api import api
|
|
||||||
from CTFd.auth import auth
|
|
||||||
from CTFd.challenges import challenges
|
|
||||||
from CTFd.errors import render_error
|
|
||||||
from CTFd.events import events
|
|
||||||
from CTFd.scoreboard import scoreboard
|
|
||||||
from CTFd.teams import teams
|
|
||||||
from CTFd.users import users
|
|
||||||
from CTFd.views import views
|
|
||||||
|
|
||||||
app.register_blueprint(views)
|
|
||||||
app.register_blueprint(teams)
|
|
||||||
app.register_blueprint(users)
|
|
||||||
app.register_blueprint(challenges)
|
|
||||||
app.register_blueprint(scoreboard)
|
|
||||||
app.register_blueprint(auth)
|
|
||||||
app.register_blueprint(api)
|
|
||||||
app.register_blueprint(events)
|
|
||||||
|
|
||||||
app.register_blueprint(admin)
|
|
||||||
|
|
||||||
for code in {403, 404, 500, 502}:
|
|
||||||
app.register_error_handler(code, render_error)
|
|
||||||
|
|
||||||
init_logs(app)
|
|
||||||
init_events(app)
|
|
||||||
init_plugins(app)
|
|
||||||
|
|
||||||
return app
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
import csv # noqa: I001
|
|
||||||
import datetime
|
|
||||||
import os
|
|
||||||
from io import StringIO
|
|
||||||
|
|
||||||
from flask import Blueprint, abort
|
|
||||||
from flask import current_app as app
|
|
||||||
from flask import (
|
|
||||||
jsonify,
|
|
||||||
redirect,
|
|
||||||
render_template,
|
|
||||||
render_template_string,
|
|
||||||
request,
|
|
||||||
send_file,
|
|
||||||
url_for,
|
|
||||||
)
|
|
||||||
|
|
||||||
admin = Blueprint("admin", __name__)
|
|
||||||
|
|
||||||
# isort:imports-firstparty
|
|
||||||
from CTFd.admin import challenges # noqa: F401,I001
|
|
||||||
from CTFd.admin import notifications # noqa: F401,I001
|
|
||||||
from CTFd.admin import pages # noqa: F401,I001
|
|
||||||
from CTFd.admin import scoreboard # noqa: F401,I001
|
|
||||||
from CTFd.admin import statistics # noqa: F401,I001
|
|
||||||
from CTFd.admin import submissions # noqa: F401,I001
|
|
||||||
from CTFd.admin import teams # noqa: F401,I001
|
|
||||||
from CTFd.admin import users # noqa: F401,I001
|
|
||||||
from CTFd.cache import (
|
|
||||||
cache,
|
|
||||||
clear_challenges,
|
|
||||||
clear_config,
|
|
||||||
clear_pages,
|
|
||||||
clear_standings,
|
|
||||||
)
|
|
||||||
from CTFd.models import (
|
|
||||||
Awards,
|
|
||||||
Challenges,
|
|
||||||
Configs,
|
|
||||||
Notifications,
|
|
||||||
Pages,
|
|
||||||
Solves,
|
|
||||||
Submissions,
|
|
||||||
Teams,
|
|
||||||
Tracking,
|
|
||||||
Unlocks,
|
|
||||||
Users,
|
|
||||||
db,
|
|
||||||
)
|
|
||||||
from CTFd.utils import config as ctf_config
|
|
||||||
from CTFd.utils import get_config, set_config
|
|
||||||
from CTFd.utils.csv import dump_csv, load_challenges_csv, load_teams_csv, load_users_csv
|
|
||||||
from CTFd.utils.decorators import admins_only
|
|
||||||
from CTFd.utils.exports import background_import_ctf
|
|
||||||
from CTFd.utils.exports import export_ctf as export_ctf_util
|
|
||||||
from CTFd.utils.security.auth import logout_user
|
|
||||||
from CTFd.utils.uploads import delete_file
|
|
||||||
from CTFd.utils.user import is_admin
|
|
||||||
|
|
||||||
|
|
||||||
@admin.route("/admin", methods=["GET"])
|
|
||||||
def view():
|
|
||||||
if is_admin():
|
|
||||||
return redirect(url_for("admin.statistics"))
|
|
||||||
return redirect(url_for("auth.login"))
|
|
||||||
|
|
||||||
|
|
||||||
@admin.route("/admin/plugins/<plugin>", methods=["GET", "POST"])
|
|
||||||
@admins_only
|
|
||||||
def plugin(plugin):
|
|
||||||
if request.method == "GET":
|
|
||||||
plugins_path = os.path.join(app.root_path, "plugins")
|
|
||||||
|
|
||||||
config_html_plugins = [
|
|
||||||
name
|
|
||||||
for name in os.listdir(plugins_path)
|
|
||||||
if os.path.isfile(os.path.join(plugins_path, name, "config.html"))
|
|
||||||
]
|
|
||||||
|
|
||||||
if plugin in config_html_plugins:
|
|
||||||
config_html = open(
|
|
||||||
os.path.join(app.root_path, "plugins", plugin, "config.html")
|
|
||||||
).read()
|
|
||||||
return render_template_string(config_html)
|
|
||||||
abort(404)
|
|
||||||
elif request.method == "POST":
|
|
||||||
for k, v in request.form.items():
|
|
||||||
if k == "nonce":
|
|
||||||
continue
|
|
||||||
set_config(k, v)
|
|
||||||
with app.app_context():
|
|
||||||
clear_config()
|
|
||||||
return "1"
|
|
||||||
|
|
||||||
|
|
||||||
@admin.route("/admin/import", methods=["GET", "POST"])
|
|
||||||
@admins_only
|
|
||||||
def import_ctf():
|
|
||||||
if request.method == "GET":
|
|
||||||
start_time = cache.get("import_start_time")
|
|
||||||
end_time = cache.get("import_end_time")
|
|
||||||
import_status = cache.get("import_status")
|
|
||||||
import_error = cache.get("import_error")
|
|
||||||
return render_template(
|
|
||||||
"admin/import.html",
|
|
||||||
start_time=start_time,
|
|
||||||
end_time=end_time,
|
|
||||||
import_status=import_status,
|
|
||||||
import_error=import_error,
|
|
||||||
)
|
|
||||||
elif request.method == "POST":
|
|
||||||
backup = request.files["backup"]
|
|
||||||
background_import_ctf(backup)
|
|
||||||
return redirect(url_for("admin.import_ctf"))
|
|
||||||
|
|
||||||
|
|
||||||
@admin.route("/admin/export", methods=["GET", "POST"])
|
|
||||||
@admins_only
|
|
||||||
def export_ctf():
|
|
||||||
backup = export_ctf_util()
|
|
||||||
ctf_name = ctf_config.ctf_name()
|
|
||||||
day = datetime.datetime.now().strftime("%Y-%m-%d_%T")
|
|
||||||
full_name = u"{}.{}.zip".format(ctf_name, day)
|
|
||||||
return send_file(
|
|
||||||
backup, cache_timeout=-1, as_attachment=True, attachment_filename=full_name
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.route("/admin/import/csv", methods=["POST"])
|
|
||||||
@admins_only
|
|
||||||
def import_csv():
|
|
||||||
csv_type = request.form["csv_type"]
|
|
||||||
# Try really hard to load data in properly no matter what nonsense Excel gave you
|
|
||||||
raw = request.files["csv_file"].stream.read()
|
|
||||||
try:
|
|
||||||
csvdata = raw.decode("utf-8-sig")
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
try:
|
|
||||||
csvdata = raw.decode("cp1252")
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
csvdata = raw.decode("latin-1")
|
|
||||||
csvfile = StringIO(csvdata)
|
|
||||||
|
|
||||||
loaders = {
|
|
||||||
"challenges": load_challenges_csv,
|
|
||||||
"users": load_users_csv,
|
|
||||||
"teams": load_teams_csv,
|
|
||||||
}
|
|
||||||
|
|
||||||
loader = loaders[csv_type]
|
|
||||||
reader = csv.DictReader(csvfile)
|
|
||||||
success = loader(reader)
|
|
||||||
if success is True:
|
|
||||||
return redirect(url_for("admin.config"))
|
|
||||||
else:
|
|
||||||
return jsonify(success), 500
|
|
||||||
|
|
||||||
|
|
||||||
@admin.route("/admin/export/csv")
|
|
||||||
@admins_only
|
|
||||||
def export_csv():
|
|
||||||
table = request.args.get("table")
|
|
||||||
|
|
||||||
output = dump_csv(name=table)
|
|
||||||
|
|
||||||
return send_file(
|
|
||||||
output,
|
|
||||||
as_attachment=True,
|
|
||||||
cache_timeout=-1,
|
|
||||||
attachment_filename="{name}-{table}.csv".format(
|
|
||||||
name=ctf_config.ctf_name(), table=table
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.route("/admin/config", methods=["GET", "POST"])
|
|
||||||
@admins_only
|
|
||||||
def config():
|
|
||||||
# Clear the config cache so that we don't get stale values
|
|
||||||
clear_config()
|
|
||||||
|
|
||||||
configs = Configs.query.all()
|
|
||||||
configs = {c.key: get_config(c.key) for c in configs}
|
|
||||||
|
|
||||||
themes = ctf_config.get_themes()
|
|
||||||
|
|
||||||
# Remove current theme but ignore failure
|
|
||||||
try:
|
|
||||||
themes.remove(get_config("ctf_theme"))
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return render_template("admin/config.html", themes=themes, **configs)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.route("/admin/reset", methods=["GET", "POST"])
|
|
||||||
@admins_only
|
|
||||||
def reset():
|
|
||||||
if request.method == "POST":
|
|
||||||
require_setup = False
|
|
||||||
logout = False
|
|
||||||
next_url = url_for("admin.statistics")
|
|
||||||
|
|
||||||
data = request.form
|
|
||||||
|
|
||||||
if data.get("pages"):
|
|
||||||
_pages = Pages.query.all()
|
|
||||||
for p in _pages:
|
|
||||||
for f in p.files:
|
|
||||||
delete_file(file_id=f.id)
|
|
||||||
|
|
||||||
Pages.query.delete()
|
|
||||||
|
|
||||||
if data.get("notifications"):
|
|
||||||
Notifications.query.delete()
|
|
||||||
|
|
||||||
if data.get("challenges"):
|
|
||||||
_challenges = Challenges.query.all()
|
|
||||||
for c in _challenges:
|
|
||||||
for f in c.files:
|
|
||||||
delete_file(file_id=f.id)
|
|
||||||
Challenges.query.delete()
|
|
||||||
|
|
||||||
if data.get("accounts"):
|
|
||||||
Users.query.delete()
|
|
||||||
Teams.query.delete()
|
|
||||||
require_setup = True
|
|
||||||
logout = True
|
|
||||||
|
|
||||||
if data.get("submissions"):
|
|
||||||
Solves.query.delete()
|
|
||||||
Submissions.query.delete()
|
|
||||||
Awards.query.delete()
|
|
||||||
Unlocks.query.delete()
|
|
||||||
Tracking.query.delete()
|
|
||||||
|
|
||||||
if require_setup:
|
|
||||||
set_config("setup", False)
|
|
||||||
cache.clear()
|
|
||||||
logout_user()
|
|
||||||
next_url = url_for("views.setup")
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
clear_pages()
|
|
||||||
clear_standings()
|
|
||||||
clear_challenges()
|
|
||||||
clear_config()
|
|
||||||
|
|
||||||
if logout is True:
|
|
||||||
cache.clear()
|
|
||||||
logout_user()
|
|
||||||
|
|
||||||
db.session.close()
|
|
||||||
return redirect(next_url)
|
|
||||||
|
|
||||||
return render_template("admin/reset.html")
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
from flask import abort, render_template, request, url_for
|
|
||||||
|
|
||||||
from CTFd.admin import admin
|
|
||||||
from CTFd.models import Challenges, Flags, Solves
|
|
||||||
from CTFd.plugins.challenges import CHALLENGE_CLASSES, get_chal_class
|
|
||||||
from CTFd.utils.decorators import admins_only
|
|
||||||
|
|
||||||
|
|
||||||
@admin.route("/admin/challenges")
|
|
||||||
@admins_only
|
|
||||||
def challenges_listing():
|
|
||||||
q = request.args.get("q")
|
|
||||||
field = request.args.get("field")
|
|
||||||
filters = []
|
|
||||||
|
|
||||||
if q:
|
|
||||||
# The field exists as an exposed column
|
|
||||||
if Challenges.__mapper__.has_property(field):
|
|
||||||
filters.append(getattr(Challenges, field).like("%{}%".format(q)))
|
|
||||||
|
|
||||||
query = Challenges.query.filter(*filters).order_by(Challenges.id.asc())
|
|
||||||
challenges = query.all()
|
|
||||||
total = query.count()
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
"admin/challenges/challenges.html",
|
|
||||||
challenges=challenges,
|
|
||||||
total=total,
|
|
||||||
q=q,
|
|
||||||
field=field,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.route("/admin/challenges/<int:challenge_id>")
|
|
||||||
@admins_only
|
|
||||||
def challenges_detail(challenge_id):
|
|
||||||
challenges = dict(
|
|
||||||
Challenges.query.with_entities(Challenges.id, Challenges.name).all()
|
|
||||||
)
|
|
||||||
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
|
|
||||||
solves = (
|
|
||||||
Solves.query.filter_by(challenge_id=challenge.id)
|
|
||||||
.order_by(Solves.date.asc())
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
flags = Flags.query.filter_by(challenge_id=challenge.id).all()
|
|
||||||
|
|
||||||
try:
|
|
||||||
challenge_class = get_chal_class(challenge.type)
|
|
||||||
except KeyError:
|
|
||||||
abort(
|
|
||||||
500,
|
|
||||||
f"The underlying challenge type ({challenge.type}) is not installed. This challenge can not be loaded.",
|
|
||||||
)
|
|
||||||
|
|
||||||
update_j2 = render_template(
|
|
||||||
challenge_class.templates["update"].lstrip("/"), challenge=challenge
|
|
||||||
)
|
|
||||||
|
|
||||||
update_script = url_for(
|
|
||||||
"views.static_html", route=challenge_class.scripts["update"].lstrip("/")
|
|
||||||
)
|
|
||||||
return render_template(
|
|
||||||
"admin/challenges/challenge.html",
|
|
||||||
update_template=update_j2,
|
|
||||||
update_script=update_script,
|
|
||||||
challenge=challenge,
|
|
||||||
challenges=challenges,
|
|
||||||
solves=solves,
|
|
||||||
flags=flags,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.route("/admin/challenges/new")
|
|
||||||
@admins_only
|
|
||||||
def challenges_new():
|
|
||||||
types = CHALLENGE_CLASSES.keys()
|
|
||||||
return render_template("admin/challenges/new.html", types=types)
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
from flask import render_template
|
|
||||||
|
|
||||||
from CTFd.admin import admin
|
|
||||||
from CTFd.models import Notifications
|
|
||||||
from CTFd.utils.decorators import admins_only
|
|
||||||
|
|
||||||
|
|
||||||
@admin.route("/admin/notifications")
|
|
||||||
@admins_only
|
|
||||||
def notifications():
|
|
||||||
notifs = Notifications.query.order_by(Notifications.id.desc()).all()
|
|
||||||
return render_template("admin/notifications.html", notifications=notifs)
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
from flask import render_template, request
|
|
||||||
|
|
||||||
from CTFd.admin import admin
|
|
||||||
from CTFd.models import Pages
|
|
||||||
from CTFd.schemas.pages import PageSchema
|
|
||||||
from CTFd.utils import markdown
|
|
||||||
from CTFd.utils.decorators import admins_only
|
|
||||||
|
|
||||||
|
|
||||||
@admin.route("/admin/pages")
|
|
||||||
@admins_only
|
|
||||||
def pages_listing():
|
|
||||||
pages = Pages.query.all()
|
|
||||||
return render_template("admin/pages.html", pages=pages)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.route("/admin/pages/new")
|
|
||||||
@admins_only
|
|
||||||
def pages_new():
|
|
||||||
return render_template("admin/editor.html")
|
|
||||||
|
|
||||||
|
|
||||||
@admin.route("/admin/pages/preview", methods=["POST"])
|
|
||||||
@admins_only
|
|
||||||
def pages_preview():
|
|
||||||
# We only care about content.
|
|
||||||
# Loading other attributes improperly will cause Marshmallow to incorrectly return a dict
|
|
||||||
data = {
|
|
||||||
"content": request.form.get("content"),
|
|
||||||
"format": request.form.get("format"),
|
|
||||||
}
|
|
||||||
schema = PageSchema()
|
|
||||||
page = schema.load(data)
|
|
||||||
return render_template("page.html", content=page.data.html)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.route("/admin/pages/<int:page_id>")
|
|
||||||
@admins_only
|
|
||||||
def pages_detail(page_id):
|
|
||||||
page = Pages.query.filter_by(id=page_id).first_or_404()
|
|
||||||
page_op = request.args.get("operation")
|
|
||||||
|
|
||||||
if request.method == "GET" and page_op == "preview":
|
|
||||||
return render_template("page.html", content=markdown(page.content))
|
|
||||||
|
|
||||||
if request.method == "GET" and page_op == "create":
|
|
||||||
return render_template("admin/editor.html")
|
|
||||||
|
|
||||||
return render_template("admin/editor.html", page=page)
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
from flask import render_template
|
|
||||||
|
|
||||||
from CTFd.admin import admin
|
|
||||||
from CTFd.utils.config import is_teams_mode
|
|
||||||
from CTFd.utils.decorators import admins_only
|
|
||||||
from CTFd.utils.scores import get_standings, get_user_standings
|
|
||||||
|
|
||||||
|
|
||||||
@admin.route("/admin/scoreboard")
|
|
||||||
@admins_only
|
|
||||||
def scoreboard_listing():
|
|
||||||
standings = get_standings(admin=True)
|
|
||||||
user_standings = get_user_standings(admin=True) if is_teams_mode() else None
|
|
||||||
return render_template(
|
|
||||||
"admin/scoreboard.html", standings=standings, user_standings=user_standings
|
|
||||||
)
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
from flask import render_template
|
|
||||||
|
|
||||||
from CTFd.admin import admin
|
|
||||||
from CTFd.models import Challenges, Fails, Solves, Teams, Tracking, Users, db
|
|
||||||
from CTFd.utils.decorators import admins_only
|
|
||||||
from CTFd.utils.modes import get_model
|
|
||||||
from CTFd.utils.updates import update_check
|
|
||||||
|
|
||||||
|
|
||||||
@admin.route("/admin/statistics", methods=["GET"])
|
|
||||||
@admins_only
|
|
||||||
def statistics():
|
|
||||||
update_check()
|
|
||||||
|
|
||||||
Model = get_model()
|
|
||||||
|
|
||||||
teams_registered = Teams.query.count()
|
|
||||||
users_registered = Users.query.count()
|
|
||||||
|
|
||||||
wrong_count = (
|
|
||||||
Fails.query.join(Model, Fails.account_id == Model.id)
|
|
||||||
.filter(Model.banned == False, Model.hidden == False)
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
solve_count = (
|
|
||||||
Solves.query.join(Model, Solves.account_id == Model.id)
|
|
||||||
.filter(Model.banned == False, Model.hidden == False)
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
challenge_count = Challenges.query.count()
|
|
||||||
|
|
||||||
total_points = (
|
|
||||||
Challenges.query.with_entities(db.func.sum(Challenges.value).label("sum"))
|
|
||||||
.filter_by(state="visible")
|
|
||||||
.first()
|
|
||||||
.sum
|
|
||||||
) or 0
|
|
||||||
|
|
||||||
ip_count = Tracking.query.with_entities(Tracking.ip).distinct().count()
|
|
||||||
|
|
||||||
solves_sub = (
|
|
||||||
db.session.query(
|
|
||||||
Solves.challenge_id, db.func.count(Solves.challenge_id).label("solves_cnt")
|
|
||||||
)
|
|
||||||
.join(Model, Solves.account_id == Model.id)
|
|
||||||
.filter(Model.banned == False, Model.hidden == False)
|
|
||||||
.group_by(Solves.challenge_id)
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
|
|
||||||
solves = (
|
|
||||||
db.session.query(
|
|
||||||
solves_sub.columns.challenge_id,
|
|
||||||
solves_sub.columns.solves_cnt,
|
|
||||||
Challenges.name,
|
|
||||||
)
|
|
||||||
.join(Challenges, solves_sub.columns.challenge_id == Challenges.id)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
solve_data = {}
|
|
||||||
for _chal, count, name in solves:
|
|
||||||
solve_data[name] = count
|
|
||||||
|
|
||||||
most_solved = None
|
|
||||||
least_solved = None
|
|
||||||
if len(solve_data):
|
|
||||||
most_solved = max(solve_data, key=solve_data.get)
|
|
||||||
least_solved = min(solve_data, key=solve_data.get)
|
|
||||||
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
"admin/statistics.html",
|
|
||||||
user_count=users_registered,
|
|
||||||
team_count=teams_registered,
|
|
||||||
ip_count=ip_count,
|
|
||||||
wrong_count=wrong_count,
|
|
||||||
solve_count=solve_count,
|
|
||||||
challenge_count=challenge_count,
|
|
||||||
total_points=total_points,
|
|
||||||
solve_data=solve_data,
|
|
||||||
most_solved=most_solved,
|
|
||||||
least_solved=least_solved,
|
|
||||||
)
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
from flask import render_template, request, url_for
|
|
||||||
|
|
||||||
from CTFd.admin import admin
|
|
||||||
from CTFd.models import Challenges, Submissions
|
|
||||||
from CTFd.utils.decorators import admins_only
|
|
||||||
from CTFd.utils.helpers.models import build_model_filters
|
|
||||||
from CTFd.utils.modes import get_model
|
|
||||||
|
|
||||||
|
|
||||||
@admin.route("/admin/submissions", defaults={"submission_type": None})
|
|
||||||
@admin.route("/admin/submissions/<submission_type>")
|
|
||||||
@admins_only
|
|
||||||
def submissions_listing(submission_type):
|
|
||||||
filters_by = {}
|
|
||||||
if submission_type:
|
|
||||||
filters_by["type"] = submission_type
|
|
||||||
filters = []
|
|
||||||
|
|
||||||
q = request.args.get("q")
|
|
||||||
field = request.args.get("field")
|
|
||||||
page = abs(request.args.get("page", 1, type=int))
|
|
||||||
|
|
||||||
filters = build_model_filters(
|
|
||||||
model=Submissions,
|
|
||||||
query=q,
|
|
||||||
field=field,
|
|
||||||
extra_columns={
|
|
||||||
"challenge_name": Challenges.name,
|
|
||||||
"account_id": Submissions.account_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
Model = get_model()
|
|
||||||
|
|
||||||
submissions = (
|
|
||||||
Submissions.query.filter_by(**filters_by)
|
|
||||||
.filter(*filters)
|
|
||||||
.join(Challenges)
|
|
||||||
.join(Model)
|
|
||||||
.order_by(Submissions.date.desc())
|
|
||||||
.paginate(page=page, per_page=50)
|
|
||||||
)
|
|
||||||
|
|
||||||
args = dict(request.args)
|
|
||||||
args.pop("page", 1)
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
"admin/submissions.html",
|
|
||||||
submissions=submissions,
|
|
||||||
prev_page=url_for(
|
|
||||||
request.endpoint,
|
|
||||||
submission_type=submission_type,
|
|
||||||
page=submissions.prev_num,
|
|
||||||
**args
|
|
||||||
),
|
|
||||||
next_page=url_for(
|
|
||||||
request.endpoint,
|
|
||||||
submission_type=submission_type,
|
|
||||||
page=submissions.next_num,
|
|
||||||
**args
|
|
||||||
),
|
|
||||||
type=submission_type,
|
|
||||||
q=q,
|
|
||||||
field=field,
|
|
||||||
)
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
from flask import render_template, request, url_for
|
|
||||||
from sqlalchemy.sql import not_
|
|
||||||
|
|
||||||
from CTFd.admin import admin
|
|
||||||
from CTFd.models import Challenges, Teams, Tracking
|
|
||||||
from CTFd.utils.decorators import admins_only
|
|
||||||
|
|
||||||
|
|
||||||
@admin.route("/admin/teams")
|
|
||||||
@admins_only
|
|
||||||
def teams_listing():
|
|
||||||
q = request.args.get("q")
|
|
||||||
field = request.args.get("field")
|
|
||||||
page = abs(request.args.get("page", 1, type=int))
|
|
||||||
filters = []
|
|
||||||
|
|
||||||
if q:
|
|
||||||
# The field exists as an exposed column
|
|
||||||
if Teams.__mapper__.has_property(field):
|
|
||||||
filters.append(getattr(Teams, field).like("%{}%".format(q)))
|
|
||||||
|
|
||||||
teams = (
|
|
||||||
Teams.query.filter(*filters)
|
|
||||||
.order_by(Teams.id.asc())
|
|
||||||
.paginate(page=page, per_page=50)
|
|
||||||
)
|
|
||||||
|
|
||||||
args = dict(request.args)
|
|
||||||
args.pop("page", 1)
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
"admin/teams/teams.html",
|
|
||||||
teams=teams,
|
|
||||||
prev_page=url_for(request.endpoint, page=teams.prev_num, **args),
|
|
||||||
next_page=url_for(request.endpoint, page=teams.next_num, **args),
|
|
||||||
q=q,
|
|
||||||
field=field,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.route("/admin/teams/new")
|
|
||||||
@admins_only
|
|
||||||
def teams_new():
|
|
||||||
return render_template("admin/teams/new.html")
|
|
||||||
|
|
||||||
|
|
||||||
@admin.route("/admin/teams/<int:team_id>")
|
|
||||||
@admins_only
|
|
||||||
def teams_detail(team_id):
|
|
||||||
team = Teams.query.filter_by(id=team_id).first_or_404()
|
|
||||||
|
|
||||||
# Get members
|
|
||||||
members = team.members
|
|
||||||
member_ids = [member.id for member in members]
|
|
||||||
|
|
||||||
# Get Solves for all members
|
|
||||||
solves = team.get_solves(admin=True)
|
|
||||||
fails = team.get_fails(admin=True)
|
|
||||||
awards = team.get_awards(admin=True)
|
|
||||||
score = team.get_score(admin=True)
|
|
||||||
place = team.get_place(admin=True)
|
|
||||||
|
|
||||||
# Get missing Challenges for all members
|
|
||||||
# TODO: How do you mark a missing challenge for a team?
|
|
||||||
solve_ids = [s.challenge_id for s in solves]
|
|
||||||
missing = Challenges.query.filter(not_(Challenges.id.in_(solve_ids))).all()
|
|
||||||
|
|
||||||
# Get addresses for all members
|
|
||||||
addrs = (
|
|
||||||
Tracking.query.filter(Tracking.user_id.in_(member_ids))
|
|
||||||
.order_by(Tracking.date.desc())
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
"admin/teams/team.html",
|
|
||||||
team=team,
|
|
||||||
members=members,
|
|
||||||
score=score,
|
|
||||||
place=place,
|
|
||||||
solves=solves,
|
|
||||||
fails=fails,
|
|
||||||
missing=missing,
|
|
||||||
awards=awards,
|
|
||||||
addrs=addrs,
|
|
||||||
)
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
from flask import render_template, request, url_for
|
|
||||||
from sqlalchemy.sql import not_
|
|
||||||
|
|
||||||
from CTFd.admin import admin
|
|
||||||
from CTFd.models import Challenges, Tracking, Users
|
|
||||||
from CTFd.utils import get_config
|
|
||||||
from CTFd.utils.decorators import admins_only
|
|
||||||
from CTFd.utils.modes import TEAMS_MODE
|
|
||||||
|
|
||||||
|
|
||||||
@admin.route("/admin/users")
|
|
||||||
@admins_only
|
|
||||||
def users_listing():
|
|
||||||
q = request.args.get("q")
|
|
||||||
field = request.args.get("field")
|
|
||||||
page = abs(request.args.get("page", 1, type=int))
|
|
||||||
filters = []
|
|
||||||
users = []
|
|
||||||
|
|
||||||
if q:
|
|
||||||
# The field exists as an exposed column
|
|
||||||
if Users.__mapper__.has_property(field):
|
|
||||||
filters.append(getattr(Users, field).like("%{}%".format(q)))
|
|
||||||
|
|
||||||
if q and field == "ip":
|
|
||||||
users = (
|
|
||||||
Users.query.join(Tracking, Users.id == Tracking.user_id)
|
|
||||||
.filter(Tracking.ip.like("%{}%".format(q)))
|
|
||||||
.order_by(Users.id.asc())
|
|
||||||
.paginate(page=page, per_page=50)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
users = (
|
|
||||||
Users.query.filter(*filters)
|
|
||||||
.order_by(Users.id.asc())
|
|
||||||
.paginate(page=page, per_page=50)
|
|
||||||
)
|
|
||||||
|
|
||||||
args = dict(request.args)
|
|
||||||
args.pop("page", 1)
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
"admin/users/users.html",
|
|
||||||
users=users,
|
|
||||||
prev_page=url_for(request.endpoint, page=users.prev_num, **args),
|
|
||||||
next_page=url_for(request.endpoint, page=users.next_num, **args),
|
|
||||||
q=q,
|
|
||||||
field=field,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.route("/admin/users/new")
|
|
||||||
@admins_only
|
|
||||||
def users_new():
|
|
||||||
return render_template("admin/users/new.html")
|
|
||||||
|
|
||||||
|
|
||||||
@admin.route("/admin/users/<int:user_id>")
|
|
||||||
@admins_only
|
|
||||||
def users_detail(user_id):
|
|
||||||
# Get user object
|
|
||||||
user = Users.query.filter_by(id=user_id).first_or_404()
|
|
||||||
|
|
||||||
# Get the user's solves
|
|
||||||
solves = user.get_solves(admin=True)
|
|
||||||
|
|
||||||
# Get challenges that the user is missing
|
|
||||||
if get_config("user_mode") == TEAMS_MODE:
|
|
||||||
if user.team:
|
|
||||||
all_solves = user.team.get_solves(admin=True)
|
|
||||||
else:
|
|
||||||
all_solves = user.get_solves(admin=True)
|
|
||||||
else:
|
|
||||||
all_solves = user.get_solves(admin=True)
|
|
||||||
|
|
||||||
solve_ids = [s.challenge_id for s in all_solves]
|
|
||||||
missing = Challenges.query.filter(not_(Challenges.id.in_(solve_ids))).all()
|
|
||||||
|
|
||||||
# Get IP addresses that the User has used
|
|
||||||
addrs = (
|
|
||||||
Tracking.query.filter_by(user_id=user_id).order_by(Tracking.date.desc()).all()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get Fails
|
|
||||||
fails = user.get_fails(admin=True)
|
|
||||||
|
|
||||||
# Get Awards
|
|
||||||
awards = user.get_awards(admin=True)
|
|
||||||
|
|
||||||
# Check if the user has an account (team or user)
|
|
||||||
# so that we don't throw an error if they dont
|
|
||||||
if user.account:
|
|
||||||
score = user.account.get_score(admin=True)
|
|
||||||
place = user.account.get_place(admin=True)
|
|
||||||
else:
|
|
||||||
score = None
|
|
||||||
place = None
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
"admin/users/user.html",
|
|
||||||
solves=solves,
|
|
||||||
user=user,
|
|
||||||
addrs=addrs,
|
|
||||||
score=score,
|
|
||||||
missing=missing,
|
|
||||||
place=place,
|
|
||||||
fails=fails,
|
|
||||||
awards=awards,
|
|
||||||
)
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
from flask import Blueprint, current_app
|
|
||||||
from flask_restx import Api
|
|
||||||
|
|
||||||
from CTFd.api.v1.awards import awards_namespace
|
|
||||||
from CTFd.api.v1.challenges import challenges_namespace
|
|
||||||
from CTFd.api.v1.comments import comments_namespace
|
|
||||||
from CTFd.api.v1.config import configs_namespace
|
|
||||||
from CTFd.api.v1.files import files_namespace
|
|
||||||
from CTFd.api.v1.flags import flags_namespace
|
|
||||||
from CTFd.api.v1.hints import hints_namespace
|
|
||||||
from CTFd.api.v1.notifications import notifications_namespace
|
|
||||||
from CTFd.api.v1.pages import pages_namespace
|
|
||||||
from CTFd.api.v1.schemas import (
|
|
||||||
APIDetailedSuccessResponse,
|
|
||||||
APISimpleErrorResponse,
|
|
||||||
APISimpleSuccessResponse,
|
|
||||||
)
|
|
||||||
from CTFd.api.v1.scoreboard import scoreboard_namespace
|
|
||||||
from CTFd.api.v1.statistics import statistics_namespace
|
|
||||||
from CTFd.api.v1.submissions import submissions_namespace
|
|
||||||
from CTFd.api.v1.tags import tags_namespace
|
|
||||||
from CTFd.api.v1.teams import teams_namespace
|
|
||||||
from CTFd.api.v1.tokens import tokens_namespace
|
|
||||||
from CTFd.api.v1.topics import topics_namespace
|
|
||||||
from CTFd.api.v1.unlocks import unlocks_namespace
|
|
||||||
from CTFd.api.v1.users import users_namespace
|
|
||||||
|
|
||||||
api = Blueprint("api", __name__, url_prefix="/api/v1")
|
|
||||||
CTFd_API_v1 = Api(
|
|
||||||
api,
|
|
||||||
version="v1",
|
|
||||||
doc=current_app.config.get("SWAGGER_UI_ENDPOINT"),
|
|
||||||
authorizations={
|
|
||||||
"AccessToken": {
|
|
||||||
"type": "apiKey",
|
|
||||||
"in": "header",
|
|
||||||
"name": "Authorization",
|
|
||||||
"description": "Generate access token in the settings page of your user account.",
|
|
||||||
},
|
|
||||||
"ContentType": {
|
|
||||||
"type": "apiKey",
|
|
||||||
"in": "header",
|
|
||||||
"name": "Content-Type",
|
|
||||||
"description": "Must be set to `application/json`",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
security=["AccessToken", "ContentType"],
|
|
||||||
)
|
|
||||||
|
|
||||||
CTFd_API_v1.schema_model("APISimpleErrorResponse", APISimpleErrorResponse.schema())
|
|
||||||
CTFd_API_v1.schema_model(
|
|
||||||
"APIDetailedSuccessResponse", APIDetailedSuccessResponse.schema()
|
|
||||||
)
|
|
||||||
CTFd_API_v1.schema_model("APISimpleSuccessResponse", APISimpleSuccessResponse.schema())
|
|
||||||
|
|
||||||
CTFd_API_v1.add_namespace(challenges_namespace, "/challenges")
|
|
||||||
CTFd_API_v1.add_namespace(tags_namespace, "/tags")
|
|
||||||
CTFd_API_v1.add_namespace(topics_namespace, "/topics")
|
|
||||||
CTFd_API_v1.add_namespace(awards_namespace, "/awards")
|
|
||||||
CTFd_API_v1.add_namespace(hints_namespace, "/hints")
|
|
||||||
CTFd_API_v1.add_namespace(flags_namespace, "/flags")
|
|
||||||
CTFd_API_v1.add_namespace(submissions_namespace, "/submissions")
|
|
||||||
CTFd_API_v1.add_namespace(scoreboard_namespace, "/scoreboard")
|
|
||||||
CTFd_API_v1.add_namespace(teams_namespace, "/teams")
|
|
||||||
CTFd_API_v1.add_namespace(users_namespace, "/users")
|
|
||||||
CTFd_API_v1.add_namespace(statistics_namespace, "/statistics")
|
|
||||||
CTFd_API_v1.add_namespace(files_namespace, "/files")
|
|
||||||
CTFd_API_v1.add_namespace(notifications_namespace, "/notifications")
|
|
||||||
CTFd_API_v1.add_namespace(configs_namespace, "/configs")
|
|
||||||
CTFd_API_v1.add_namespace(pages_namespace, "/pages")
|
|
||||||
CTFd_API_v1.add_namespace(unlocks_namespace, "/unlocks")
|
|
||||||
CTFd_API_v1.add_namespace(tokens_namespace, "/tokens")
|
|
||||||
CTFd_API_v1.add_namespace(comments_namespace, "/comments")
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
from typing import List
|
|
||||||
|
|
||||||
from flask import request
|
|
||||||
from flask_restx import Namespace, Resource
|
|
||||||
|
|
||||||
from CTFd.api.v1.helpers.request import validate_args
|
|
||||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
|
||||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
|
||||||
from CTFd.cache import clear_standings
|
|
||||||
from CTFd.constants import RawEnum
|
|
||||||
from CTFd.models import Awards, Users, db
|
|
||||||
from CTFd.schemas.awards import AwardSchema
|
|
||||||
from CTFd.utils.config import is_teams_mode
|
|
||||||
from CTFd.utils.decorators import admins_only
|
|
||||||
from CTFd.utils.helpers.models import build_model_filters
|
|
||||||
|
|
||||||
awards_namespace = Namespace("awards", description="Endpoint to retrieve Awards")
|
|
||||||
|
|
||||||
AwardModel = sqlalchemy_to_pydantic(Awards)
|
|
||||||
|
|
||||||
|
|
||||||
class AwardDetailedSuccessResponse(APIDetailedSuccessResponse):
|
|
||||||
data: AwardModel
|
|
||||||
|
|
||||||
|
|
||||||
class AwardListSuccessResponse(APIListSuccessResponse):
|
|
||||||
data: List[AwardModel]
|
|
||||||
|
|
||||||
|
|
||||||
awards_namespace.schema_model(
|
|
||||||
"AwardDetailedSuccessResponse", AwardDetailedSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
awards_namespace.schema_model(
|
|
||||||
"AwardListSuccessResponse", AwardListSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@awards_namespace.route("")
|
|
||||||
class AwardList(Resource):
|
|
||||||
@admins_only
|
|
||||||
@awards_namespace.doc(
|
|
||||||
description="Endpoint to list Award objects in bulk",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "AwardListSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@validate_args(
|
|
||||||
{
|
|
||||||
"user_id": (int, None),
|
|
||||||
"team_id": (int, None),
|
|
||||||
"type": (str, None),
|
|
||||||
"value": (int, None),
|
|
||||||
"category": (int, None),
|
|
||||||
"icon": (int, None),
|
|
||||||
"q": (str, None),
|
|
||||||
"field": (
|
|
||||||
RawEnum(
|
|
||||||
"AwardFields",
|
|
||||||
{
|
|
||||||
"name": "name",
|
|
||||||
"description": "description",
|
|
||||||
"category": "category",
|
|
||||||
"icon": "icon",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
location="query",
|
|
||||||
)
|
|
||||||
def get(self, query_args):
|
|
||||||
q = query_args.pop("q", None)
|
|
||||||
field = str(query_args.pop("field", None))
|
|
||||||
filters = build_model_filters(model=Awards, query=q, field=field)
|
|
||||||
|
|
||||||
awards = Awards.query.filter_by(**query_args).filter(*filters).all()
|
|
||||||
schema = AwardSchema(many=True)
|
|
||||||
response = schema.dump(awards)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@awards_namespace.doc(
|
|
||||||
description="Endpoint to create an Award object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "AwardListSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def post(self):
|
|
||||||
req = request.get_json()
|
|
||||||
|
|
||||||
# Force a team_id if in team mode and unspecified
|
|
||||||
if is_teams_mode():
|
|
||||||
team_id = req.get("team_id")
|
|
||||||
if team_id is None:
|
|
||||||
user = Users.query.filter_by(id=req["user_id"]).first_or_404()
|
|
||||||
if user.team_id is None:
|
|
||||||
return (
|
|
||||||
{
|
|
||||||
"success": False,
|
|
||||||
"errors": {
|
|
||||||
"team_id": [
|
|
||||||
"User doesn't have a team to associate award with"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
},
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
req["team_id"] = user.team_id
|
|
||||||
|
|
||||||
schema = AwardSchema()
|
|
||||||
|
|
||||||
response = schema.load(req, session=db.session)
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
db.session.add(response.data)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
response = schema.dump(response.data)
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
# Delete standings cache because awards can change scores
|
|
||||||
clear_standings()
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
|
|
||||||
@awards_namespace.route("/<award_id>")
|
|
||||||
@awards_namespace.param("award_id", "An Award ID")
|
|
||||||
class Award(Resource):
|
|
||||||
@admins_only
|
|
||||||
@awards_namespace.doc(
|
|
||||||
description="Endpoint to get a specific Award object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "AwardDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def get(self, award_id):
|
|
||||||
award = Awards.query.filter_by(id=award_id).first_or_404()
|
|
||||||
response = AwardSchema().dump(award)
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@awards_namespace.doc(
|
|
||||||
description="Endpoint to delete an Award object",
|
|
||||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
|
||||||
)
|
|
||||||
def delete(self, award_id):
|
|
||||||
award = Awards.query.filter_by(id=award_id).first_or_404()
|
|
||||||
db.session.delete(award)
|
|
||||||
db.session.commit()
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
# Delete standings cache because awards can change scores
|
|
||||||
clear_standings()
|
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
@@ -1,813 +0,0 @@
|
|||||||
from typing import List # noqa: I001
|
|
||||||
|
|
||||||
from flask import abort, render_template, request, url_for
|
|
||||||
from flask_restx import Namespace, Resource
|
|
||||||
from sqlalchemy.sql import and_
|
|
||||||
|
|
||||||
from CTFd.api.v1.helpers.request import validate_args
|
|
||||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
|
||||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
|
||||||
from CTFd.cache import clear_challenges, clear_standings
|
|
||||||
from CTFd.constants import RawEnum
|
|
||||||
from CTFd.models import ChallengeFiles as ChallengeFilesModel
|
|
||||||
from CTFd.models import Challenges
|
|
||||||
from CTFd.models import ChallengeTopics as ChallengeTopicsModel
|
|
||||||
from CTFd.models import Fails, Flags, Hints, HintUnlocks, Solves, Submissions, Tags, db
|
|
||||||
from CTFd.plugins.challenges import CHALLENGE_CLASSES, get_chal_class
|
|
||||||
from CTFd.schemas.challenges import ChallengeSchema
|
|
||||||
from CTFd.schemas.flags import FlagSchema
|
|
||||||
from CTFd.schemas.hints import HintSchema
|
|
||||||
from CTFd.schemas.tags import TagSchema
|
|
||||||
from CTFd.utils import config, get_config
|
|
||||||
from CTFd.utils import user as current_user
|
|
||||||
from CTFd.utils.challenges import (
|
|
||||||
get_all_challenges,
|
|
||||||
get_solve_counts_for_challenges,
|
|
||||||
get_solve_ids_for_user_id,
|
|
||||||
get_solves_for_challenge_id,
|
|
||||||
)
|
|
||||||
from CTFd.utils.config.visibility import (
|
|
||||||
accounts_visible,
|
|
||||||
challenges_visible,
|
|
||||||
scores_visible,
|
|
||||||
)
|
|
||||||
from CTFd.utils.dates import ctf_ended, ctf_paused, ctftime
|
|
||||||
from CTFd.utils.decorators import (
|
|
||||||
admins_only,
|
|
||||||
during_ctf_time_only,
|
|
||||||
require_verified_emails,
|
|
||||||
)
|
|
||||||
from CTFd.utils.decorators.visibility import (
|
|
||||||
check_challenge_visibility,
|
|
||||||
check_score_visibility,
|
|
||||||
)
|
|
||||||
from CTFd.utils.logging import log
|
|
||||||
from CTFd.utils.security.signing import serialize
|
|
||||||
from CTFd.utils.user import (
|
|
||||||
authed,
|
|
||||||
get_current_team,
|
|
||||||
get_current_team_attrs,
|
|
||||||
get_current_user,
|
|
||||||
get_current_user_attrs,
|
|
||||||
is_admin,
|
|
||||||
)
|
|
||||||
|
|
||||||
challenges_namespace = Namespace(
|
|
||||||
"challenges", description="Endpoint to retrieve Challenges"
|
|
||||||
)
|
|
||||||
|
|
||||||
ChallengeModel = sqlalchemy_to_pydantic(
|
|
||||||
Challenges, include={"solves": int, "solved_by_me": bool}
|
|
||||||
)
|
|
||||||
TransientChallengeModel = sqlalchemy_to_pydantic(Challenges, exclude=["id"])
|
|
||||||
|
|
||||||
|
|
||||||
class ChallengeDetailedSuccessResponse(APIDetailedSuccessResponse):
|
|
||||||
data: ChallengeModel
|
|
||||||
|
|
||||||
|
|
||||||
class ChallengeListSuccessResponse(APIListSuccessResponse):
|
|
||||||
data: List[ChallengeModel]
|
|
||||||
|
|
||||||
|
|
||||||
challenges_namespace.schema_model(
|
|
||||||
"ChallengeDetailedSuccessResponse", ChallengeDetailedSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
challenges_namespace.schema_model(
|
|
||||||
"ChallengeListSuccessResponse", ChallengeListSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@challenges_namespace.route("")
|
|
||||||
class ChallengeList(Resource):
|
|
||||||
@check_challenge_visibility
|
|
||||||
@during_ctf_time_only
|
|
||||||
@require_verified_emails
|
|
||||||
@challenges_namespace.doc(
|
|
||||||
description="Endpoint to get Challenge objects in bulk",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "ChallengeListSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@validate_args(
|
|
||||||
{
|
|
||||||
"name": (str, None),
|
|
||||||
"max_attempts": (int, None),
|
|
||||||
"value": (int, None),
|
|
||||||
"category": (str, None),
|
|
||||||
"type": (str, None),
|
|
||||||
"state": (str, None),
|
|
||||||
"q": (str, None),
|
|
||||||
"field": (
|
|
||||||
RawEnum(
|
|
||||||
"ChallengeFields",
|
|
||||||
{
|
|
||||||
"name": "name",
|
|
||||||
"description": "description",
|
|
||||||
"category": "category",
|
|
||||||
"type": "type",
|
|
||||||
"state": "state",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
location="query",
|
|
||||||
)
|
|
||||||
def get(self, query_args):
|
|
||||||
# Require a team if in teams mode
|
|
||||||
# TODO: Convert this into a re-useable decorator
|
|
||||||
# TODO: The require_team decorator doesnt work because of no admin passthru
|
|
||||||
if get_current_user_attrs():
|
|
||||||
if is_admin():
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
if config.is_teams_mode() and get_current_team_attrs() is None:
|
|
||||||
abort(403)
|
|
||||||
|
|
||||||
# Build filtering queries
|
|
||||||
q = query_args.pop("q", None)
|
|
||||||
field = str(query_args.pop("field", None))
|
|
||||||
|
|
||||||
# Admins get a shortcut to see all challenges despite pre-requisites
|
|
||||||
admin_view = is_admin() and request.args.get("view") == "admin"
|
|
||||||
|
|
||||||
# Get a cached mapping of challenge_id to solve_count
|
|
||||||
solve_counts = get_solve_counts_for_challenges(admin=admin_view)
|
|
||||||
|
|
||||||
# Get list of solve_ids for current user
|
|
||||||
if authed():
|
|
||||||
user = get_current_user()
|
|
||||||
user_solves = get_solve_ids_for_user_id(user_id=user.id)
|
|
||||||
else:
|
|
||||||
user_solves = set()
|
|
||||||
|
|
||||||
# Aggregate the query results into the hashes defined at the top of
|
|
||||||
# this block for later use
|
|
||||||
if scores_visible() and accounts_visible():
|
|
||||||
solve_count_dfl = 0
|
|
||||||
else:
|
|
||||||
# Empty out the solves_count if we're hiding scores/accounts
|
|
||||||
solve_counts = {}
|
|
||||||
# This is necessary to match the challenge detail API which returns
|
|
||||||
# `None` for the solve count if visiblity checks fail
|
|
||||||
solve_count_dfl = None
|
|
||||||
|
|
||||||
chal_q = get_all_challenges(admin=admin_view, field=field, q=q, **query_args)
|
|
||||||
|
|
||||||
# Iterate through the list of challenges, adding to the object which
|
|
||||||
# will be JSONified back to the client
|
|
||||||
response = []
|
|
||||||
tag_schema = TagSchema(view="user", many=True)
|
|
||||||
|
|
||||||
# Gather all challenge IDs so that we can determine invalid challenge prereqs
|
|
||||||
all_challenge_ids = {
|
|
||||||
c.id for c in Challenges.query.with_entities(Challenges.id).all()
|
|
||||||
}
|
|
||||||
for challenge in chal_q:
|
|
||||||
if challenge.requirements:
|
|
||||||
requirements = challenge.requirements.get("prerequisites", [])
|
|
||||||
anonymize = challenge.requirements.get("anonymize")
|
|
||||||
prereqs = set(requirements).intersection(all_challenge_ids)
|
|
||||||
if user_solves >= prereqs or admin_view:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
if anonymize:
|
|
||||||
response.append(
|
|
||||||
{
|
|
||||||
"id": challenge.id,
|
|
||||||
"type": "hidden",
|
|
||||||
"name": "???",
|
|
||||||
"value": 0,
|
|
||||||
"solves": None,
|
|
||||||
"solved_by_me": False,
|
|
||||||
"category": "???",
|
|
||||||
"tags": [],
|
|
||||||
"template": "",
|
|
||||||
"script": "",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
# Fallthrough to continue
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
challenge_type = get_chal_class(challenge.type)
|
|
||||||
except KeyError:
|
|
||||||
# Challenge type does not exist. Fall through to next challenge.
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Challenge passes all checks, add it to response
|
|
||||||
response.append(
|
|
||||||
{
|
|
||||||
"id": challenge.id,
|
|
||||||
"type": challenge_type.name,
|
|
||||||
"name": challenge.name,
|
|
||||||
"value": challenge.value,
|
|
||||||
"solves": solve_counts.get(challenge.id, solve_count_dfl),
|
|
||||||
"solved_by_me": challenge.id in user_solves,
|
|
||||||
"category": challenge.category,
|
|
||||||
"tags": tag_schema.dump(challenge.tags).data,
|
|
||||||
"template": challenge_type.templates["view"],
|
|
||||||
"script": challenge_type.scripts["view"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
db.session.close()
|
|
||||||
return {"success": True, "data": response}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@challenges_namespace.doc(
|
|
||||||
description="Endpoint to create a Challenge object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "ChallengeDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def post(self):
|
|
||||||
data = request.form or request.get_json()
|
|
||||||
|
|
||||||
# Load data through schema for validation but not for insertion
|
|
||||||
schema = ChallengeSchema()
|
|
||||||
response = schema.load(data)
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
challenge_type = data["type"]
|
|
||||||
challenge_class = get_chal_class(challenge_type)
|
|
||||||
challenge = challenge_class.create(request)
|
|
||||||
response = challenge_class.read(challenge)
|
|
||||||
|
|
||||||
clear_challenges()
|
|
||||||
|
|
||||||
return {"success": True, "data": response}
|
|
||||||
|
|
||||||
|
|
||||||
@challenges_namespace.route("/types")
|
|
||||||
class ChallengeTypes(Resource):
|
|
||||||
@admins_only
|
|
||||||
def get(self):
|
|
||||||
response = {}
|
|
||||||
|
|
||||||
for class_id in CHALLENGE_CLASSES:
|
|
||||||
challenge_class = CHALLENGE_CLASSES.get(class_id)
|
|
||||||
response[challenge_class.id] = {
|
|
||||||
"id": challenge_class.id,
|
|
||||||
"name": challenge_class.name,
|
|
||||||
"templates": challenge_class.templates,
|
|
||||||
"scripts": challenge_class.scripts,
|
|
||||||
"create": render_template(
|
|
||||||
challenge_class.templates["create"].lstrip("/")
|
|
||||||
),
|
|
||||||
}
|
|
||||||
return {"success": True, "data": response}
|
|
||||||
|
|
||||||
|
|
||||||
@challenges_namespace.route("/<challenge_id>")
|
|
||||||
class Challenge(Resource):
|
|
||||||
@check_challenge_visibility
|
|
||||||
@during_ctf_time_only
|
|
||||||
@require_verified_emails
|
|
||||||
@challenges_namespace.doc(
|
|
||||||
description="Endpoint to get a specific Challenge object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "ChallengeDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def get(self, challenge_id):
|
|
||||||
if is_admin():
|
|
||||||
chal = Challenges.query.filter(Challenges.id == challenge_id).first_or_404()
|
|
||||||
else:
|
|
||||||
chal = Challenges.query.filter(
|
|
||||||
Challenges.id == challenge_id,
|
|
||||||
and_(Challenges.state != "hidden", Challenges.state != "locked"),
|
|
||||||
).first_or_404()
|
|
||||||
|
|
||||||
try:
|
|
||||||
chal_class = get_chal_class(chal.type)
|
|
||||||
except KeyError:
|
|
||||||
abort(
|
|
||||||
500,
|
|
||||||
f"The underlying challenge type ({chal.type}) is not installed. This challenge can not be loaded.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if chal.requirements:
|
|
||||||
requirements = chal.requirements.get("prerequisites", [])
|
|
||||||
anonymize = chal.requirements.get("anonymize")
|
|
||||||
# Gather all challenge IDs so that we can determine invalid challenge prereqs
|
|
||||||
all_challenge_ids = {
|
|
||||||
c.id for c in Challenges.query.with_entities(Challenges.id).all()
|
|
||||||
}
|
|
||||||
if challenges_visible():
|
|
||||||
user = get_current_user()
|
|
||||||
if user:
|
|
||||||
solve_ids = (
|
|
||||||
Solves.query.with_entities(Solves.challenge_id)
|
|
||||||
.filter_by(account_id=user.account_id)
|
|
||||||
.order_by(Solves.challenge_id.asc())
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# We need to handle the case where a user is viewing challenges anonymously
|
|
||||||
solve_ids = []
|
|
||||||
solve_ids = {value for value, in solve_ids}
|
|
||||||
prereqs = set(requirements).intersection(all_challenge_ids)
|
|
||||||
if solve_ids >= prereqs or is_admin():
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
if anonymize:
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"data": {
|
|
||||||
"id": chal.id,
|
|
||||||
"type": "hidden",
|
|
||||||
"name": "???",
|
|
||||||
"value": 0,
|
|
||||||
"solves": None,
|
|
||||||
"solved_by_me": False,
|
|
||||||
"category": "???",
|
|
||||||
"tags": [],
|
|
||||||
"template": "",
|
|
||||||
"script": "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
abort(403)
|
|
||||||
else:
|
|
||||||
abort(403)
|
|
||||||
|
|
||||||
tags = [
|
|
||||||
tag["value"] for tag in TagSchema("user", many=True).dump(chal.tags).data
|
|
||||||
]
|
|
||||||
|
|
||||||
unlocked_hints = set()
|
|
||||||
hints = []
|
|
||||||
if authed():
|
|
||||||
user = get_current_user()
|
|
||||||
team = get_current_team()
|
|
||||||
|
|
||||||
# TODO: Convert this into a re-useable decorator
|
|
||||||
if is_admin():
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
if config.is_teams_mode() and team is None:
|
|
||||||
abort(403)
|
|
||||||
|
|
||||||
unlocked_hints = {
|
|
||||||
u.target
|
|
||||||
for u in HintUnlocks.query.filter_by(
|
|
||||||
type="hints", account_id=user.account_id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
files = []
|
|
||||||
for f in chal.files:
|
|
||||||
token = {
|
|
||||||
"user_id": user.id,
|
|
||||||
"team_id": team.id if team else None,
|
|
||||||
"file_id": f.id,
|
|
||||||
}
|
|
||||||
files.append(
|
|
||||||
url_for("views.files", path=f.location, token=serialize(token))
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
files = [url_for("views.files", path=f.location) for f in chal.files]
|
|
||||||
|
|
||||||
for hint in Hints.query.filter_by(challenge_id=chal.id).all():
|
|
||||||
if hint.id in unlocked_hints or ctf_ended():
|
|
||||||
hints.append(
|
|
||||||
{"id": hint.id, "cost": hint.cost, "content": hint.content}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
hints.append({"id": hint.id, "cost": hint.cost})
|
|
||||||
|
|
||||||
response = chal_class.read(challenge=chal)
|
|
||||||
|
|
||||||
# Get list of solve_ids for current user
|
|
||||||
if authed():
|
|
||||||
user = get_current_user()
|
|
||||||
user_solves = get_solve_ids_for_user_id(user_id=user.id)
|
|
||||||
else:
|
|
||||||
user_solves = []
|
|
||||||
|
|
||||||
solves_count = get_solve_counts_for_challenges(challenge_id=chal.id)
|
|
||||||
if solves_count:
|
|
||||||
challenge_id = chal.id
|
|
||||||
solve_count = solves_count.get(chal.id)
|
|
||||||
solved_by_user = challenge_id in user_solves
|
|
||||||
else:
|
|
||||||
solve_count, solved_by_user = 0, False
|
|
||||||
|
|
||||||
# Hide solve counts if we are hiding solves/accounts
|
|
||||||
if scores_visible() is False or accounts_visible() is False:
|
|
||||||
solve_count = None
|
|
||||||
|
|
||||||
if authed():
|
|
||||||
# Get current attempts for the user
|
|
||||||
attempts = Submissions.query.filter_by(
|
|
||||||
account_id=user.account_id, challenge_id=challenge_id
|
|
||||||
).count()
|
|
||||||
else:
|
|
||||||
attempts = 0
|
|
||||||
|
|
||||||
response["solves"] = solve_count
|
|
||||||
response["solved_by_me"] = solved_by_user
|
|
||||||
response["attempts"] = attempts
|
|
||||||
response["files"] = files
|
|
||||||
response["tags"] = tags
|
|
||||||
response["hints"] = hints
|
|
||||||
|
|
||||||
response["view"] = render_template(
|
|
||||||
chal_class.templates["view"].lstrip("/"),
|
|
||||||
solves=solve_count,
|
|
||||||
solved_by_me=solved_by_user,
|
|
||||||
files=files,
|
|
||||||
tags=tags,
|
|
||||||
hints=[Hints(**h) for h in hints],
|
|
||||||
max_attempts=chal.max_attempts,
|
|
||||||
attempts=attempts,
|
|
||||||
challenge=chal,
|
|
||||||
)
|
|
||||||
|
|
||||||
db.session.close()
|
|
||||||
return {"success": True, "data": response}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@challenges_namespace.doc(
|
|
||||||
description="Endpoint to edit a specific Challenge object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "ChallengeDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def patch(self, challenge_id):
|
|
||||||
data = request.get_json()
|
|
||||||
|
|
||||||
# Load data through schema for validation but not for insertion
|
|
||||||
schema = ChallengeSchema()
|
|
||||||
response = schema.load(data)
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
|
|
||||||
challenge_class = get_chal_class(challenge.type)
|
|
||||||
challenge = challenge_class.update(challenge, request)
|
|
||||||
response = challenge_class.read(challenge)
|
|
||||||
|
|
||||||
clear_standings()
|
|
||||||
clear_challenges()
|
|
||||||
|
|
||||||
return {"success": True, "data": response}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@challenges_namespace.doc(
|
|
||||||
description="Endpoint to delete a specific Challenge object",
|
|
||||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
|
||||||
)
|
|
||||||
def delete(self, challenge_id):
|
|
||||||
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
|
|
||||||
chal_class = get_chal_class(challenge.type)
|
|
||||||
chal_class.delete(challenge)
|
|
||||||
|
|
||||||
clear_standings()
|
|
||||||
clear_challenges()
|
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
|
|
||||||
@challenges_namespace.route("/attempt")
|
|
||||||
class ChallengeAttempt(Resource):
|
|
||||||
@check_challenge_visibility
|
|
||||||
@during_ctf_time_only
|
|
||||||
@require_verified_emails
|
|
||||||
def post(self):
|
|
||||||
if authed() is False:
|
|
||||||
return {"success": True, "data": {"status": "authentication_required"}}, 403
|
|
||||||
|
|
||||||
if request.content_type != "application/json":
|
|
||||||
request_data = request.form
|
|
||||||
else:
|
|
||||||
request_data = request.get_json()
|
|
||||||
|
|
||||||
challenge_id = request_data.get("challenge_id")
|
|
||||||
|
|
||||||
if current_user.is_admin():
|
|
||||||
preview = request.args.get("preview", False)
|
|
||||||
if preview:
|
|
||||||
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
|
|
||||||
chal_class = get_chal_class(challenge.type)
|
|
||||||
status, message = chal_class.attempt(challenge, request)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"data": {
|
|
||||||
"status": "correct" if status else "incorrect",
|
|
||||||
"message": message,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if ctf_paused():
|
|
||||||
return (
|
|
||||||
{
|
|
||||||
"success": True,
|
|
||||||
"data": {
|
|
||||||
"status": "paused",
|
|
||||||
"message": "{} is paused".format(config.ctf_name()),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
403,
|
|
||||||
)
|
|
||||||
|
|
||||||
user = get_current_user()
|
|
||||||
team = get_current_team()
|
|
||||||
|
|
||||||
# TODO: Convert this into a re-useable decorator
|
|
||||||
if config.is_teams_mode() and team is None:
|
|
||||||
abort(403)
|
|
||||||
|
|
||||||
fails = Fails.query.filter_by(
|
|
||||||
account_id=user.account_id, challenge_id=challenge_id
|
|
||||||
).count()
|
|
||||||
|
|
||||||
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
|
|
||||||
|
|
||||||
if challenge.state == "hidden":
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
if challenge.state == "locked":
|
|
||||||
abort(403)
|
|
||||||
|
|
||||||
if challenge.requirements:
|
|
||||||
requirements = challenge.requirements.get("prerequisites", [])
|
|
||||||
solve_ids = (
|
|
||||||
Solves.query.with_entities(Solves.challenge_id)
|
|
||||||
.filter_by(account_id=user.account_id)
|
|
||||||
.order_by(Solves.challenge_id.asc())
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
solve_ids = {solve_id for solve_id, in solve_ids}
|
|
||||||
# Gather all challenge IDs so that we can determine invalid challenge prereqs
|
|
||||||
all_challenge_ids = {
|
|
||||||
c.id for c in Challenges.query.with_entities(Challenges.id).all()
|
|
||||||
}
|
|
||||||
prereqs = set(requirements).intersection(all_challenge_ids)
|
|
||||||
if solve_ids >= prereqs:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
abort(403)
|
|
||||||
|
|
||||||
chal_class = get_chal_class(challenge.type)
|
|
||||||
|
|
||||||
# Anti-bruteforce / submitting Flags too quickly
|
|
||||||
kpm = current_user.get_wrong_submissions_per_minute(user.account_id)
|
|
||||||
kpm_limit = int(get_config("incorrect_submissions_per_min", default=10))
|
|
||||||
if kpm > kpm_limit:
|
|
||||||
if ctftime():
|
|
||||||
chal_class.fail(
|
|
||||||
user=user, team=team, challenge=challenge, request=request
|
|
||||||
)
|
|
||||||
log(
|
|
||||||
"submissions",
|
|
||||||
"[{date}] {name} submitted {submission} on {challenge_id} with kpm {kpm} [TOO FAST]",
|
|
||||||
name=user.name,
|
|
||||||
submission=request_data.get("submission", "").encode("utf-8"),
|
|
||||||
challenge_id=challenge_id,
|
|
||||||
kpm=kpm,
|
|
||||||
)
|
|
||||||
# Submitting too fast
|
|
||||||
return (
|
|
||||||
{
|
|
||||||
"success": True,
|
|
||||||
"data": {
|
|
||||||
"status": "ratelimited",
|
|
||||||
"message": "You're submitting flags too fast. Slow down.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
429,
|
|
||||||
)
|
|
||||||
|
|
||||||
solves = Solves.query.filter_by(
|
|
||||||
account_id=user.account_id, challenge_id=challenge_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
# Challenge not solved yet
|
|
||||||
if not solves:
|
|
||||||
# Hit max attempts
|
|
||||||
max_tries = challenge.max_attempts
|
|
||||||
if max_tries and fails >= max_tries > 0:
|
|
||||||
return (
|
|
||||||
{
|
|
||||||
"success": True,
|
|
||||||
"data": {
|
|
||||||
"status": "incorrect",
|
|
||||||
"message": "You have 0 tries remaining",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
403,
|
|
||||||
)
|
|
||||||
|
|
||||||
status, message = chal_class.attempt(challenge, request)
|
|
||||||
if status: # The challenge plugin says the input is right
|
|
||||||
if ctftime() or current_user.is_admin():
|
|
||||||
chal_class.solve(
|
|
||||||
user=user, team=team, challenge=challenge, request=request
|
|
||||||
)
|
|
||||||
clear_standings()
|
|
||||||
clear_challenges()
|
|
||||||
|
|
||||||
log(
|
|
||||||
"submissions",
|
|
||||||
"[{date}] {name} submitted {submission} on {challenge_id} with kpm {kpm} [CORRECT]",
|
|
||||||
name=user.name,
|
|
||||||
submission=request_data.get("submission", "").encode("utf-8"),
|
|
||||||
challenge_id=challenge_id,
|
|
||||||
kpm=kpm,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"data": {"status": "correct", "message": message},
|
|
||||||
}
|
|
||||||
else: # The challenge plugin says the input is wrong
|
|
||||||
if ctftime() or current_user.is_admin():
|
|
||||||
chal_class.fail(
|
|
||||||
user=user, team=team, challenge=challenge, request=request
|
|
||||||
)
|
|
||||||
clear_standings()
|
|
||||||
clear_challenges()
|
|
||||||
|
|
||||||
log(
|
|
||||||
"submissions",
|
|
||||||
"[{date}] {name} submitted {submission} on {challenge_id} with kpm {kpm} [WRONG]",
|
|
||||||
name=user.name,
|
|
||||||
submission=request_data.get("submission", "").encode("utf-8"),
|
|
||||||
challenge_id=challenge_id,
|
|
||||||
kpm=kpm,
|
|
||||||
)
|
|
||||||
|
|
||||||
if max_tries:
|
|
||||||
# Off by one since fails has changed since it was gotten
|
|
||||||
attempts_left = max_tries - fails - 1
|
|
||||||
tries_str = "tries"
|
|
||||||
if attempts_left == 1:
|
|
||||||
tries_str = "try"
|
|
||||||
# Add a punctuation mark if there isn't one
|
|
||||||
if message[-1] not in "!().;?[]{}":
|
|
||||||
message = message + "."
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"data": {
|
|
||||||
"status": "incorrect",
|
|
||||||
"message": "{} You have {} {} remaining.".format(
|
|
||||||
message, attempts_left, tries_str
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"data": {"status": "incorrect", "message": message},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Challenge already solved
|
|
||||||
else:
|
|
||||||
log(
|
|
||||||
"submissions",
|
|
||||||
"[{date}] {name} submitted {submission} on {challenge_id} with kpm {kpm} [ALREADY SOLVED]",
|
|
||||||
name=user.name,
|
|
||||||
submission=request_data.get("submission", "").encode("utf-8"),
|
|
||||||
challenge_id=challenge_id,
|
|
||||||
kpm=kpm,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"data": {
|
|
||||||
"status": "already_solved",
|
|
||||||
"message": "You already solved this",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@challenges_namespace.route("/<challenge_id>/solves")
|
|
||||||
class ChallengeSolves(Resource):
|
|
||||||
@check_challenge_visibility
|
|
||||||
@check_score_visibility
|
|
||||||
@during_ctf_time_only
|
|
||||||
@require_verified_emails
|
|
||||||
def get(self, challenge_id):
|
|
||||||
response = []
|
|
||||||
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
|
|
||||||
|
|
||||||
# TODO: Need a generic challenge visibility call.
|
|
||||||
# However, it should be stated that a solve on a gated challenge is not considered private.
|
|
||||||
if challenge.state == "hidden" and is_admin() is False:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
freeze = get_config("freeze")
|
|
||||||
if freeze:
|
|
||||||
preview = request.args.get("preview")
|
|
||||||
if (is_admin() is False) or (is_admin() is True and preview):
|
|
||||||
freeze = True
|
|
||||||
elif is_admin() is True:
|
|
||||||
freeze = False
|
|
||||||
|
|
||||||
response = get_solves_for_challenge_id(challenge_id=challenge_id, freeze=freeze)
|
|
||||||
|
|
||||||
return {"success": True, "data": response}
|
|
||||||
|
|
||||||
|
|
||||||
@challenges_namespace.route("/<challenge_id>/files")
|
|
||||||
class ChallengeFiles(Resource):
|
|
||||||
@admins_only
|
|
||||||
def get(self, challenge_id):
|
|
||||||
response = []
|
|
||||||
|
|
||||||
challenge_files = ChallengeFilesModel.query.filter_by(
|
|
||||||
challenge_id=challenge_id
|
|
||||||
).all()
|
|
||||||
|
|
||||||
for f in challenge_files:
|
|
||||||
response.append({"id": f.id, "type": f.type, "location": f.location})
|
|
||||||
return {"success": True, "data": response}
|
|
||||||
|
|
||||||
|
|
||||||
@challenges_namespace.route("/<challenge_id>/tags")
|
|
||||||
class ChallengeTags(Resource):
|
|
||||||
@admins_only
|
|
||||||
def get(self, challenge_id):
|
|
||||||
response = []
|
|
||||||
|
|
||||||
tags = Tags.query.filter_by(challenge_id=challenge_id).all()
|
|
||||||
|
|
||||||
for t in tags:
|
|
||||||
response.append(
|
|
||||||
{"id": t.id, "challenge_id": t.challenge_id, "value": t.value}
|
|
||||||
)
|
|
||||||
return {"success": True, "data": response}
|
|
||||||
|
|
||||||
|
|
||||||
@challenges_namespace.route("/<challenge_id>/topics")
|
|
||||||
class ChallengeTopics(Resource):
|
|
||||||
@admins_only
|
|
||||||
def get(self, challenge_id):
|
|
||||||
response = []
|
|
||||||
|
|
||||||
topics = ChallengeTopicsModel.query.filter_by(challenge_id=challenge_id).all()
|
|
||||||
|
|
||||||
for t in topics:
|
|
||||||
response.append(
|
|
||||||
{
|
|
||||||
"id": t.id,
|
|
||||||
"challenge_id": t.challenge_id,
|
|
||||||
"topic_id": t.topic_id,
|
|
||||||
"value": t.topic.value,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return {"success": True, "data": response}
|
|
||||||
|
|
||||||
|
|
||||||
@challenges_namespace.route("/<challenge_id>/hints")
|
|
||||||
class ChallengeHints(Resource):
|
|
||||||
@admins_only
|
|
||||||
def get(self, challenge_id):
|
|
||||||
hints = Hints.query.filter_by(challenge_id=challenge_id).all()
|
|
||||||
schema = HintSchema(many=True)
|
|
||||||
response = schema.dump(hints)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
|
|
||||||
@challenges_namespace.route("/<challenge_id>/flags")
|
|
||||||
class ChallengeFlags(Resource):
|
|
||||||
@admins_only
|
|
||||||
def get(self, challenge_id):
|
|
||||||
flags = Flags.query.filter_by(challenge_id=challenge_id).all()
|
|
||||||
schema = FlagSchema(many=True)
|
|
||||||
response = schema.dump(flags)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
|
|
||||||
@challenges_namespace.route("/<challenge_id>/requirements")
|
|
||||||
class ChallengeRequirements(Resource):
|
|
||||||
@admins_only
|
|
||||||
def get(self, challenge_id):
|
|
||||||
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
|
|
||||||
return {"success": True, "data": challenge.requirements}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
from typing import List
|
|
||||||
|
|
||||||
from flask import request, session
|
|
||||||
from flask_restx import Namespace, Resource
|
|
||||||
|
|
||||||
from CTFd.api.v1.helpers.request import validate_args
|
|
||||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
|
||||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
|
||||||
from CTFd.constants import RawEnum
|
|
||||||
from CTFd.models import (
|
|
||||||
ChallengeComments,
|
|
||||||
Comments,
|
|
||||||
PageComments,
|
|
||||||
TeamComments,
|
|
||||||
UserComments,
|
|
||||||
db,
|
|
||||||
)
|
|
||||||
from CTFd.schemas.comments import CommentSchema
|
|
||||||
from CTFd.utils.decorators import admins_only
|
|
||||||
from CTFd.utils.helpers.models import build_model_filters
|
|
||||||
|
|
||||||
comments_namespace = Namespace("comments", description="Endpoint to retrieve Comments")
|
|
||||||
|
|
||||||
|
|
||||||
CommentModel = sqlalchemy_to_pydantic(Comments)
|
|
||||||
|
|
||||||
|
|
||||||
class CommentDetailedSuccessResponse(APIDetailedSuccessResponse):
|
|
||||||
data: CommentModel
|
|
||||||
|
|
||||||
|
|
||||||
class CommentListSuccessResponse(APIListSuccessResponse):
|
|
||||||
data: List[CommentModel]
|
|
||||||
|
|
||||||
|
|
||||||
comments_namespace.schema_model(
|
|
||||||
"CommentDetailedSuccessResponse", CommentDetailedSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
comments_namespace.schema_model(
|
|
||||||
"CommentListSuccessResponse", CommentListSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_comment_model(data):
|
|
||||||
model = Comments
|
|
||||||
if "challenge_id" in data:
|
|
||||||
model = ChallengeComments
|
|
||||||
elif "user_id" in data:
|
|
||||||
model = UserComments
|
|
||||||
elif "team_id" in data:
|
|
||||||
model = TeamComments
|
|
||||||
elif "page_id" in data:
|
|
||||||
model = PageComments
|
|
||||||
else:
|
|
||||||
model = Comments
|
|
||||||
return model
|
|
||||||
|
|
||||||
|
|
||||||
@comments_namespace.route("")
|
|
||||||
class CommentList(Resource):
|
|
||||||
@admins_only
|
|
||||||
@comments_namespace.doc(
|
|
||||||
description="Endpoint to list Comment objects in bulk",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "CommentListSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@validate_args(
|
|
||||||
{
|
|
||||||
"challenge_id": (int, None),
|
|
||||||
"user_id": (int, None),
|
|
||||||
"team_id": (int, None),
|
|
||||||
"page_id": (int, None),
|
|
||||||
"q": (str, None),
|
|
||||||
"field": (RawEnum("CommentFields", {"content": "content"}), None),
|
|
||||||
},
|
|
||||||
location="query",
|
|
||||||
)
|
|
||||||
def get(self, query_args):
|
|
||||||
q = query_args.pop("q", None)
|
|
||||||
field = str(query_args.pop("field", None))
|
|
||||||
CommentModel = get_comment_model(data=query_args)
|
|
||||||
filters = build_model_filters(model=CommentModel, query=q, field=field)
|
|
||||||
|
|
||||||
comments = (
|
|
||||||
CommentModel.query.filter_by(**query_args)
|
|
||||||
.filter(*filters)
|
|
||||||
.order_by(CommentModel.id.desc())
|
|
||||||
.paginate(max_per_page=100)
|
|
||||||
)
|
|
||||||
schema = CommentSchema(many=True)
|
|
||||||
response = schema.dump(comments.items)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {
|
|
||||||
"meta": {
|
|
||||||
"pagination": {
|
|
||||||
"page": comments.page,
|
|
||||||
"next": comments.next_num,
|
|
||||||
"prev": comments.prev_num,
|
|
||||||
"pages": comments.pages,
|
|
||||||
"per_page": comments.per_page,
|
|
||||||
"total": comments.total,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"success": True,
|
|
||||||
"data": response.data,
|
|
||||||
}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@comments_namespace.doc(
|
|
||||||
description="Endpoint to create a Comment object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "CommentDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def post(self):
|
|
||||||
req = request.get_json()
|
|
||||||
# Always force author IDs to be the actual user
|
|
||||||
req["author_id"] = session["id"]
|
|
||||||
CommentModel = get_comment_model(data=req)
|
|
||||||
|
|
||||||
m = CommentModel(**req)
|
|
||||||
db.session.add(m)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
schema = CommentSchema()
|
|
||||||
|
|
||||||
response = schema.dump(m)
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
|
|
||||||
@comments_namespace.route("/<comment_id>")
|
|
||||||
class Comment(Resource):
|
|
||||||
@admins_only
|
|
||||||
@comments_namespace.doc(
|
|
||||||
description="Endpoint to delete a specific Comment object",
|
|
||||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
|
||||||
)
|
|
||||||
def delete(self, comment_id):
|
|
||||||
comment = Comments.query.filter_by(id=comment_id).first_or_404()
|
|
||||||
db.session.delete(comment)
|
|
||||||
db.session.commit()
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
from typing import List
|
|
||||||
|
|
||||||
from flask import request
|
|
||||||
from flask_restx import Namespace, Resource
|
|
||||||
|
|
||||||
from CTFd.api.v1.helpers.request import validate_args
|
|
||||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
|
||||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
|
||||||
from CTFd.cache import clear_challenges, clear_config, clear_standings
|
|
||||||
from CTFd.constants import RawEnum
|
|
||||||
from CTFd.models import Configs, Fields, db
|
|
||||||
from CTFd.schemas.config import ConfigSchema
|
|
||||||
from CTFd.schemas.fields import FieldSchema
|
|
||||||
from CTFd.utils import set_config
|
|
||||||
from CTFd.utils.decorators import admins_only
|
|
||||||
from CTFd.utils.helpers.models import build_model_filters
|
|
||||||
|
|
||||||
configs_namespace = Namespace("configs", description="Endpoint to retrieve Configs")
|
|
||||||
|
|
||||||
ConfigModel = sqlalchemy_to_pydantic(Configs)
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigDetailedSuccessResponse(APIDetailedSuccessResponse):
|
|
||||||
data: ConfigModel
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigListSuccessResponse(APIListSuccessResponse):
|
|
||||||
data: List[ConfigModel]
|
|
||||||
|
|
||||||
|
|
||||||
configs_namespace.schema_model(
|
|
||||||
"ConfigDetailedSuccessResponse", ConfigDetailedSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
configs_namespace.schema_model(
|
|
||||||
"ConfigListSuccessResponse", ConfigListSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@configs_namespace.route("")
|
|
||||||
class ConfigList(Resource):
|
|
||||||
@admins_only
|
|
||||||
@configs_namespace.doc(
|
|
||||||
description="Endpoint to get Config objects in bulk",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "ConfigListSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@validate_args(
|
|
||||||
{
|
|
||||||
"key": (str, None),
|
|
||||||
"value": (str, None),
|
|
||||||
"q": (str, None),
|
|
||||||
"field": (RawEnum("ConfigFields", {"key": "key", "value": "value"}), None),
|
|
||||||
},
|
|
||||||
location="query",
|
|
||||||
)
|
|
||||||
def get(self, query_args):
|
|
||||||
q = query_args.pop("q", None)
|
|
||||||
field = str(query_args.pop("field", None))
|
|
||||||
filters = build_model_filters(model=Configs, query=q, field=field)
|
|
||||||
|
|
||||||
configs = Configs.query.filter_by(**query_args).filter(*filters).all()
|
|
||||||
schema = ConfigSchema(many=True)
|
|
||||||
response = schema.dump(configs)
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@configs_namespace.doc(
|
|
||||||
description="Endpoint to get create a Config object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "ConfigDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def post(self):
|
|
||||||
req = request.get_json()
|
|
||||||
schema = ConfigSchema()
|
|
||||||
response = schema.load(req)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
db.session.add(response.data)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
response = schema.dump(response.data)
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
clear_config()
|
|
||||||
clear_standings()
|
|
||||||
clear_challenges()
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@configs_namespace.doc(
|
|
||||||
description="Endpoint to get patch Config objects in bulk",
|
|
||||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
|
||||||
)
|
|
||||||
def patch(self):
|
|
||||||
req = request.get_json()
|
|
||||||
schema = ConfigSchema()
|
|
||||||
|
|
||||||
for key, value in req.items():
|
|
||||||
response = schema.load({"key": key, "value": value})
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
set_config(key=key, value=value)
|
|
||||||
|
|
||||||
clear_config()
|
|
||||||
clear_standings()
|
|
||||||
clear_challenges()
|
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
|
|
||||||
@configs_namespace.route("/<config_key>")
|
|
||||||
class Config(Resource):
|
|
||||||
@admins_only
|
|
||||||
@configs_namespace.doc(
|
|
||||||
description="Endpoint to get a specific Config object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "ConfigDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def get(self, config_key):
|
|
||||||
config = Configs.query.filter_by(key=config_key).first_or_404()
|
|
||||||
schema = ConfigSchema()
|
|
||||||
response = schema.dump(config)
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@configs_namespace.doc(
|
|
||||||
description="Endpoint to edit a specific Config object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "ConfigDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def patch(self, config_key):
|
|
||||||
config = Configs.query.filter_by(key=config_key).first()
|
|
||||||
data = request.get_json()
|
|
||||||
if config:
|
|
||||||
schema = ConfigSchema(instance=config, partial=True)
|
|
||||||
response = schema.load(data)
|
|
||||||
else:
|
|
||||||
schema = ConfigSchema()
|
|
||||||
data["key"] = config_key
|
|
||||||
response = schema.load(data)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
db.session.add(response.data)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
response = schema.dump(response.data)
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
clear_config()
|
|
||||||
clear_standings()
|
|
||||||
clear_challenges()
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@configs_namespace.doc(
|
|
||||||
description="Endpoint to delete a Config object",
|
|
||||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
|
||||||
)
|
|
||||||
def delete(self, config_key):
|
|
||||||
config = Configs.query.filter_by(key=config_key).first_or_404()
|
|
||||||
|
|
||||||
db.session.delete(config)
|
|
||||||
db.session.commit()
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
clear_config()
|
|
||||||
clear_standings()
|
|
||||||
clear_challenges()
|
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
|
|
||||||
@configs_namespace.route("/fields")
|
|
||||||
class FieldList(Resource):
|
|
||||||
@admins_only
|
|
||||||
@validate_args(
|
|
||||||
{
|
|
||||||
"type": (str, None),
|
|
||||||
"q": (str, None),
|
|
||||||
"field": (RawEnum("FieldFields", {"description": "description"}), None),
|
|
||||||
},
|
|
||||||
location="query",
|
|
||||||
)
|
|
||||||
def get(self, query_args):
|
|
||||||
q = query_args.pop("q", None)
|
|
||||||
field = str(query_args.pop("field", None))
|
|
||||||
filters = build_model_filters(model=Fields, query=q, field=field)
|
|
||||||
|
|
||||||
fields = Fields.query.filter_by(**query_args).filter(*filters).all()
|
|
||||||
schema = FieldSchema(many=True)
|
|
||||||
|
|
||||||
response = schema.dump(fields)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
def post(self):
|
|
||||||
req = request.get_json()
|
|
||||||
schema = FieldSchema()
|
|
||||||
response = schema.load(req, session=db.session)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
db.session.add(response.data)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
response = schema.dump(response.data)
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
|
|
||||||
@configs_namespace.route("/fields/<field_id>")
|
|
||||||
class Field(Resource):
|
|
||||||
@admins_only
|
|
||||||
def get(self, field_id):
|
|
||||||
field = Fields.query.filter_by(id=field_id).first_or_404()
|
|
||||||
schema = FieldSchema()
|
|
||||||
|
|
||||||
response = schema.dump(field)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
def patch(self, field_id):
|
|
||||||
field = Fields.query.filter_by(id=field_id).first_or_404()
|
|
||||||
schema = FieldSchema()
|
|
||||||
|
|
||||||
req = request.get_json()
|
|
||||||
|
|
||||||
response = schema.load(req, session=db.session, instance=field)
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
response = schema.dump(response.data)
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
def delete(self, field_id):
|
|
||||||
field = Fields.query.filter_by(id=field_id).first_or_404()
|
|
||||||
db.session.delete(field)
|
|
||||||
db.session.commit()
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
from typing import List
|
|
||||||
|
|
||||||
from flask import request
|
|
||||||
from flask_restx import Namespace, Resource
|
|
||||||
|
|
||||||
from CTFd.api.v1.helpers.request import validate_args
|
|
||||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
|
||||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
|
||||||
from CTFd.constants import RawEnum
|
|
||||||
from CTFd.models import Files, db
|
|
||||||
from CTFd.schemas.files import FileSchema
|
|
||||||
from CTFd.utils import uploads
|
|
||||||
from CTFd.utils.decorators import admins_only
|
|
||||||
from CTFd.utils.helpers.models import build_model_filters
|
|
||||||
|
|
||||||
files_namespace = Namespace("files", description="Endpoint to retrieve Files")
|
|
||||||
|
|
||||||
FileModel = sqlalchemy_to_pydantic(Files)
|
|
||||||
|
|
||||||
|
|
||||||
class FileDetailedSuccessResponse(APIDetailedSuccessResponse):
|
|
||||||
data: FileModel
|
|
||||||
|
|
||||||
|
|
||||||
class FileListSuccessResponse(APIListSuccessResponse):
|
|
||||||
data: List[FileModel]
|
|
||||||
|
|
||||||
|
|
||||||
files_namespace.schema_model(
|
|
||||||
"FileDetailedSuccessResponse", FileDetailedSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
files_namespace.schema_model(
|
|
||||||
"FileListSuccessResponse", FileListSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@files_namespace.route("")
|
|
||||||
class FilesList(Resource):
|
|
||||||
@admins_only
|
|
||||||
@files_namespace.doc(
|
|
||||||
description="Endpoint to get file objects in bulk",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "FileListSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@validate_args(
|
|
||||||
{
|
|
||||||
"type": (str, None),
|
|
||||||
"location": (str, None),
|
|
||||||
"q": (str, None),
|
|
||||||
"field": (
|
|
||||||
RawEnum("FileFields", {"type": "type", "location": "location"}),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
location="query",
|
|
||||||
)
|
|
||||||
def get(self, query_args):
|
|
||||||
q = query_args.pop("q", None)
|
|
||||||
field = str(query_args.pop("field", None))
|
|
||||||
filters = build_model_filters(model=Files, query=q, field=field)
|
|
||||||
|
|
||||||
files = Files.query.filter_by(**query_args).filter(*filters).all()
|
|
||||||
schema = FileSchema(many=True)
|
|
||||||
response = schema.dump(files)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@files_namespace.doc(
|
|
||||||
description="Endpoint to get file objects in bulk",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "FileDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def post(self):
|
|
||||||
files = request.files.getlist("file")
|
|
||||||
# challenge_id
|
|
||||||
# page_id
|
|
||||||
|
|
||||||
objs = []
|
|
||||||
for f in files:
|
|
||||||
# uploads.upload_file(file=f, chalid=req.get('challenge'))
|
|
||||||
obj = uploads.upload_file(file=f, **request.form.to_dict())
|
|
||||||
objs.append(obj)
|
|
||||||
|
|
||||||
schema = FileSchema(many=True)
|
|
||||||
response = schema.dump(objs)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errorss}, 400
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
|
|
||||||
@files_namespace.route("/<file_id>")
|
|
||||||
class FilesDetail(Resource):
|
|
||||||
@admins_only
|
|
||||||
@files_namespace.doc(
|
|
||||||
description="Endpoint to get a specific file object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "FileDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def get(self, file_id):
|
|
||||||
f = Files.query.filter_by(id=file_id).first_or_404()
|
|
||||||
schema = FileSchema()
|
|
||||||
response = schema.dump(f)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@files_namespace.doc(
|
|
||||||
description="Endpoint to delete a file object",
|
|
||||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
|
||||||
)
|
|
||||||
def delete(self, file_id):
|
|
||||||
f = Files.query.filter_by(id=file_id).first_or_404()
|
|
||||||
|
|
||||||
uploads.delete_file(file_id=f.id)
|
|
||||||
db.session.delete(f)
|
|
||||||
db.session.commit()
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
from typing import List
|
|
||||||
|
|
||||||
from flask import request
|
|
||||||
from flask_restx import Namespace, Resource
|
|
||||||
|
|
||||||
from CTFd.api.v1.helpers.request import validate_args
|
|
||||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
|
||||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
|
||||||
from CTFd.constants import RawEnum
|
|
||||||
from CTFd.models import Flags, db
|
|
||||||
from CTFd.plugins.flags import FLAG_CLASSES, get_flag_class
|
|
||||||
from CTFd.schemas.flags import FlagSchema
|
|
||||||
from CTFd.utils.decorators import admins_only
|
|
||||||
from CTFd.utils.helpers.models import build_model_filters
|
|
||||||
|
|
||||||
flags_namespace = Namespace("flags", description="Endpoint to retrieve Flags")
|
|
||||||
|
|
||||||
FlagModel = sqlalchemy_to_pydantic(Flags)
|
|
||||||
|
|
||||||
|
|
||||||
class FlagDetailedSuccessResponse(APIDetailedSuccessResponse):
|
|
||||||
data: FlagModel
|
|
||||||
|
|
||||||
|
|
||||||
class FlagListSuccessResponse(APIListSuccessResponse):
|
|
||||||
data: List[FlagModel]
|
|
||||||
|
|
||||||
|
|
||||||
flags_namespace.schema_model(
|
|
||||||
"FlagDetailedSuccessResponse", FlagDetailedSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
flags_namespace.schema_model(
|
|
||||||
"FlagListSuccessResponse", FlagListSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@flags_namespace.route("")
|
|
||||||
class FlagList(Resource):
|
|
||||||
@admins_only
|
|
||||||
@flags_namespace.doc(
|
|
||||||
description="Endpoint to list Flag objects in bulk",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "FlagListSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@validate_args(
|
|
||||||
{
|
|
||||||
"challenge_id": (int, None),
|
|
||||||
"type": (str, None),
|
|
||||||
"content": (str, None),
|
|
||||||
"data": (str, None),
|
|
||||||
"q": (str, None),
|
|
||||||
"field": (
|
|
||||||
RawEnum(
|
|
||||||
"FlagFields", {"type": "type", "content": "content", "data": "data"}
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
location="query",
|
|
||||||
)
|
|
||||||
def get(self, query_args):
|
|
||||||
q = query_args.pop("q", None)
|
|
||||||
field = str(query_args.pop("field", None))
|
|
||||||
filters = build_model_filters(model=Flags, query=q, field=field)
|
|
||||||
|
|
||||||
flags = Flags.query.filter_by(**query_args).filter(*filters).all()
|
|
||||||
schema = FlagSchema(many=True)
|
|
||||||
response = schema.dump(flags)
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@flags_namespace.doc(
|
|
||||||
description="Endpoint to create a Flag object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "FlagDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def post(self):
|
|
||||||
req = request.get_json()
|
|
||||||
schema = FlagSchema()
|
|
||||||
response = schema.load(req, session=db.session)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
db.session.add(response.data)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
response = schema.dump(response.data)
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
|
|
||||||
@flags_namespace.route("/types", defaults={"type_name": None})
|
|
||||||
@flags_namespace.route("/types/<type_name>")
|
|
||||||
class FlagTypes(Resource):
|
|
||||||
@admins_only
|
|
||||||
def get(self, type_name):
|
|
||||||
if type_name:
|
|
||||||
flag_class = get_flag_class(type_name)
|
|
||||||
response = {"name": flag_class.name, "templates": flag_class.templates}
|
|
||||||
return {"success": True, "data": response}
|
|
||||||
else:
|
|
||||||
response = {}
|
|
||||||
for class_id in FLAG_CLASSES:
|
|
||||||
flag_class = FLAG_CLASSES.get(class_id)
|
|
||||||
response[class_id] = {
|
|
||||||
"name": flag_class.name,
|
|
||||||
"templates": flag_class.templates,
|
|
||||||
}
|
|
||||||
return {"success": True, "data": response}
|
|
||||||
|
|
||||||
|
|
||||||
@flags_namespace.route("/<flag_id>")
|
|
||||||
class Flag(Resource):
|
|
||||||
@admins_only
|
|
||||||
@flags_namespace.doc(
|
|
||||||
description="Endpoint to get a specific Flag object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "FlagDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def get(self, flag_id):
|
|
||||||
flag = Flags.query.filter_by(id=flag_id).first_or_404()
|
|
||||||
schema = FlagSchema()
|
|
||||||
response = schema.dump(flag)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
response.data["templates"] = get_flag_class(flag.type).templates
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@flags_namespace.doc(
|
|
||||||
description="Endpoint to delete a specific Flag object",
|
|
||||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
|
||||||
)
|
|
||||||
def delete(self, flag_id):
|
|
||||||
flag = Flags.query.filter_by(id=flag_id).first_or_404()
|
|
||||||
|
|
||||||
db.session.delete(flag)
|
|
||||||
db.session.commit()
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@flags_namespace.doc(
|
|
||||||
description="Endpoint to edit a specific Flag object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "FlagDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def patch(self, flag_id):
|
|
||||||
flag = Flags.query.filter_by(id=flag_id).first_or_404()
|
|
||||||
schema = FlagSchema()
|
|
||||||
req = request.get_json()
|
|
||||||
|
|
||||||
response = schema.load(req, session=db.session, instance=flag, partial=True)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
response = schema.dump(response.data)
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# This file is no longer used. If you're importing the function from here please update your imports
|
|
||||||
|
|
||||||
from CTFd.utils.helpers.models import build_model_filters as _build_model_filters
|
|
||||||
|
|
||||||
|
|
||||||
def build_model_filters(model, query, field):
|
|
||||||
print("CTFd.api.v1.helpers.models.build_model_filters has been deprecated.")
|
|
||||||
print("Please switch to using CTFd.utils.helpers.models.build_model_filters")
|
|
||||||
print(
|
|
||||||
"This function will raise an exception in a future minor release of CTFd and then be removed in a major release."
|
|
||||||
)
|
|
||||||
return _build_model_filters(model, query, field)
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
from functools import wraps
|
|
||||||
|
|
||||||
from flask import request
|
|
||||||
from pydantic import ValidationError, create_model
|
|
||||||
|
|
||||||
ARG_LOCATIONS = {
|
|
||||||
"query": lambda: request.args,
|
|
||||||
"json": lambda: request.get_json(),
|
|
||||||
"form": lambda: request.form,
|
|
||||||
"headers": lambda: request.headers,
|
|
||||||
"cookies": lambda: request.cookies,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def validate_args(spec, location):
|
|
||||||
"""
|
|
||||||
A rough implementation of webargs using pydantic schemas. You can pass a
|
|
||||||
pydantic schema as spec or create it on the fly as follows:
|
|
||||||
|
|
||||||
@validate_args({"name": (str, None), "id": (int, None)}, location="query")
|
|
||||||
"""
|
|
||||||
if isinstance(spec, dict):
|
|
||||||
spec = create_model("", **spec)
|
|
||||||
|
|
||||||
schema = spec.schema()
|
|
||||||
props = schema.get("properties", {})
|
|
||||||
required = schema.get("required", [])
|
|
||||||
|
|
||||||
for k in props:
|
|
||||||
if k in required:
|
|
||||||
props[k]["required"] = True
|
|
||||||
props[k]["in"] = location
|
|
||||||
|
|
||||||
def decorator(func):
|
|
||||||
# Inject parameters information into the Flask-Restx apidoc attribute.
|
|
||||||
# Not really a good solution. See https://github.com/CTFd/CTFd/issues/1504
|
|
||||||
apidoc = getattr(func, "__apidoc__", {"params": {}})
|
|
||||||
apidoc["params"].update(props)
|
|
||||||
func.__apidoc__ = apidoc
|
|
||||||
|
|
||||||
@wraps(func)
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
data = ARG_LOCATIONS[location]()
|
|
||||||
try:
|
|
||||||
# Try to load data according to pydantic spec
|
|
||||||
loaded = spec(**data).dict(exclude_unset=True)
|
|
||||||
except ValidationError as e:
|
|
||||||
# Handle reporting errors when invalid
|
|
||||||
resp = {}
|
|
||||||
errors = e.errors()
|
|
||||||
for err in errors:
|
|
||||||
loc = err["loc"][0]
|
|
||||||
msg = err["msg"]
|
|
||||||
resp[loc] = msg
|
|
||||||
return {"success": False, "errors": resp}, 400
|
|
||||||
return func(*args, loaded, **kwargs)
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
return decorator
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
from typing import Container, Dict, Type
|
|
||||||
|
|
||||||
from pydantic import BaseModel, create_model
|
|
||||||
from sqlalchemy.inspection import inspect
|
|
||||||
from sqlalchemy.orm.properties import ColumnProperty
|
|
||||||
|
|
||||||
|
|
||||||
def sqlalchemy_to_pydantic(
|
|
||||||
db_model: Type, *, include: Dict[str, type] = None, exclude: Container[str] = None
|
|
||||||
) -> Type[BaseModel]:
|
|
||||||
"""
|
|
||||||
Mostly copied from https://github.com/tiangolo/pydantic-sqlalchemy
|
|
||||||
"""
|
|
||||||
if exclude is None:
|
|
||||||
exclude = []
|
|
||||||
mapper = inspect(db_model)
|
|
||||||
fields = {}
|
|
||||||
for attr in mapper.attrs:
|
|
||||||
if isinstance(attr, ColumnProperty):
|
|
||||||
if attr.columns:
|
|
||||||
column = attr.columns[0]
|
|
||||||
python_type = column.type.python_type
|
|
||||||
name = attr.key
|
|
||||||
if name in exclude:
|
|
||||||
continue
|
|
||||||
default = None
|
|
||||||
if column.default is None and not column.nullable:
|
|
||||||
default = ...
|
|
||||||
fields[name] = (python_type, default)
|
|
||||||
if bool(include):
|
|
||||||
for name, python_type in include.items():
|
|
||||||
default = None
|
|
||||||
fields[name] = (python_type, default)
|
|
||||||
pydantic_model = create_model(
|
|
||||||
db_model.__name__, **fields # type: ignore
|
|
||||||
)
|
|
||||||
return pydantic_model
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
from typing import List
|
|
||||||
|
|
||||||
from flask import request
|
|
||||||
from flask_restx import Namespace, Resource
|
|
||||||
|
|
||||||
from CTFd.api.v1.helpers.request import validate_args
|
|
||||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
|
||||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
|
||||||
from CTFd.constants import RawEnum
|
|
||||||
from CTFd.models import Hints, HintUnlocks, db
|
|
||||||
from CTFd.schemas.hints import HintSchema
|
|
||||||
from CTFd.utils.decorators import admins_only, authed_only, during_ctf_time_only
|
|
||||||
from CTFd.utils.helpers.models import build_model_filters
|
|
||||||
from CTFd.utils.user import get_current_user, is_admin
|
|
||||||
|
|
||||||
hints_namespace = Namespace("hints", description="Endpoint to retrieve Hints")
|
|
||||||
|
|
||||||
HintModel = sqlalchemy_to_pydantic(Hints)
|
|
||||||
|
|
||||||
|
|
||||||
class HintDetailedSuccessResponse(APIDetailedSuccessResponse):
|
|
||||||
data: HintModel
|
|
||||||
|
|
||||||
|
|
||||||
class HintListSuccessResponse(APIListSuccessResponse):
|
|
||||||
data: List[HintModel]
|
|
||||||
|
|
||||||
|
|
||||||
hints_namespace.schema_model(
|
|
||||||
"HintDetailedSuccessResponse", HintDetailedSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
hints_namespace.schema_model(
|
|
||||||
"HintListSuccessResponse", HintListSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@hints_namespace.route("")
|
|
||||||
class HintList(Resource):
|
|
||||||
@admins_only
|
|
||||||
@hints_namespace.doc(
|
|
||||||
description="Endpoint to list Hint objects in bulk",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "HintListSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@validate_args(
|
|
||||||
{
|
|
||||||
"type": (str, None),
|
|
||||||
"challenge_id": (int, None),
|
|
||||||
"content": (str, None),
|
|
||||||
"cost": (int, None),
|
|
||||||
"q": (str, None),
|
|
||||||
"field": (
|
|
||||||
RawEnum("HintFields", {"type": "type", "content": "content"}),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
location="query",
|
|
||||||
)
|
|
||||||
def get(self, query_args):
|
|
||||||
q = query_args.pop("q", None)
|
|
||||||
field = str(query_args.pop("field", None))
|
|
||||||
filters = build_model_filters(model=Hints, query=q, field=field)
|
|
||||||
|
|
||||||
hints = Hints.query.filter_by(**query_args).filter(*filters).all()
|
|
||||||
response = HintSchema(many=True, view="locked").dump(hints)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@hints_namespace.doc(
|
|
||||||
description="Endpoint to create a Hint object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "HintDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def post(self):
|
|
||||||
req = request.get_json()
|
|
||||||
schema = HintSchema(view="admin")
|
|
||||||
response = schema.load(req, session=db.session)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
db.session.add(response.data)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
response = schema.dump(response.data)
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
|
|
||||||
@hints_namespace.route("/<hint_id>")
|
|
||||||
class Hint(Resource):
|
|
||||||
@during_ctf_time_only
|
|
||||||
@authed_only
|
|
||||||
@hints_namespace.doc(
|
|
||||||
description="Endpoint to get a specific Hint object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "HintDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def get(self, hint_id):
|
|
||||||
user = get_current_user()
|
|
||||||
hint = Hints.query.filter_by(id=hint_id).first_or_404()
|
|
||||||
|
|
||||||
if hint.requirements:
|
|
||||||
requirements = hint.requirements.get("prerequisites", [])
|
|
||||||
|
|
||||||
# Get the IDs of all hints that the user has unlocked
|
|
||||||
all_unlocks = HintUnlocks.query.filter_by(account_id=user.account_id).all()
|
|
||||||
unlock_ids = {unlock.target for unlock in all_unlocks}
|
|
||||||
|
|
||||||
# Get the IDs of all free hints
|
|
||||||
free_hints = Hints.query.filter_by(cost=0).all()
|
|
||||||
free_ids = {h.id for h in free_hints}
|
|
||||||
|
|
||||||
# Add free hints to unlocked IDs
|
|
||||||
unlock_ids.update(free_ids)
|
|
||||||
|
|
||||||
# Filter out hint IDs that don't exist
|
|
||||||
all_hint_ids = {h.id for h in Hints.query.with_entities(Hints.id).all()}
|
|
||||||
prereqs = set(requirements).intersection(all_hint_ids)
|
|
||||||
|
|
||||||
# If the user has the necessary unlocks or is admin we should allow them to view
|
|
||||||
if unlock_ids >= prereqs or is_admin():
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
return (
|
|
||||||
{
|
|
||||||
"success": False,
|
|
||||||
"errors": {
|
|
||||||
"requirements": [
|
|
||||||
"You must unlock other hints before accessing this hint"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
},
|
|
||||||
403,
|
|
||||||
)
|
|
||||||
|
|
||||||
view = "unlocked"
|
|
||||||
if hint.cost:
|
|
||||||
view = "locked"
|
|
||||||
unlocked = HintUnlocks.query.filter_by(
|
|
||||||
account_id=user.account_id, target=hint.id
|
|
||||||
).first()
|
|
||||||
if unlocked:
|
|
||||||
view = "unlocked"
|
|
||||||
|
|
||||||
if is_admin():
|
|
||||||
if request.args.get("preview", False):
|
|
||||||
view = "admin"
|
|
||||||
|
|
||||||
response = HintSchema(view=view).dump(hint)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@hints_namespace.doc(
|
|
||||||
description="Endpoint to edit a specific Hint object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "HintDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def patch(self, hint_id):
|
|
||||||
hint = Hints.query.filter_by(id=hint_id).first_or_404()
|
|
||||||
req = request.get_json()
|
|
||||||
|
|
||||||
schema = HintSchema(view="admin")
|
|
||||||
response = schema.load(req, instance=hint, partial=True, session=db.session)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
db.session.add(response.data)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
response = schema.dump(response.data)
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@hints_namespace.doc(
|
|
||||||
description="Endpoint to delete a specific Tag object",
|
|
||||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
|
||||||
)
|
|
||||||
def delete(self, hint_id):
|
|
||||||
hint = Hints.query.filter_by(id=hint_id).first_or_404()
|
|
||||||
db.session.delete(hint)
|
|
||||||
db.session.commit()
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
from typing import List
|
|
||||||
|
|
||||||
from flask import current_app, make_response, request
|
|
||||||
from flask_restx import Namespace, Resource
|
|
||||||
|
|
||||||
from CTFd.api.v1.helpers.request import validate_args
|
|
||||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
|
||||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
|
||||||
from CTFd.constants import RawEnum
|
|
||||||
from CTFd.models import Notifications, db
|
|
||||||
from CTFd.schemas.notifications import NotificationSchema
|
|
||||||
from CTFd.utils.decorators import admins_only
|
|
||||||
from CTFd.utils.helpers.models import build_model_filters
|
|
||||||
|
|
||||||
notifications_namespace = Namespace(
|
|
||||||
"notifications", description="Endpoint to retrieve Notifications"
|
|
||||||
)
|
|
||||||
|
|
||||||
NotificationModel = sqlalchemy_to_pydantic(Notifications)
|
|
||||||
TransientNotificationModel = sqlalchemy_to_pydantic(Notifications, exclude=["id"])
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationDetailedSuccessResponse(APIDetailedSuccessResponse):
|
|
||||||
data: NotificationModel
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationListSuccessResponse(APIListSuccessResponse):
|
|
||||||
data: List[NotificationModel]
|
|
||||||
|
|
||||||
|
|
||||||
notifications_namespace.schema_model(
|
|
||||||
"NotificationDetailedSuccessResponse", NotificationDetailedSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
notifications_namespace.schema_model(
|
|
||||||
"NotificationListSuccessResponse", NotificationListSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@notifications_namespace.route("")
|
|
||||||
class NotificantionList(Resource):
|
|
||||||
@notifications_namespace.doc(
|
|
||||||
description="Endpoint to get notification objects in bulk",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "NotificationListSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@validate_args(
|
|
||||||
{
|
|
||||||
"title": (str, None),
|
|
||||||
"content": (str, None),
|
|
||||||
"user_id": (int, None),
|
|
||||||
"team_id": (int, None),
|
|
||||||
"q": (str, None),
|
|
||||||
"field": (
|
|
||||||
RawEnum("NotificationFields", {"title": "title", "content": "content"}),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
"since_id": (int, None),
|
|
||||||
},
|
|
||||||
location="query",
|
|
||||||
)
|
|
||||||
def get(self, query_args):
|
|
||||||
q = query_args.pop("q", None)
|
|
||||||
field = str(query_args.pop("field", None))
|
|
||||||
filters = build_model_filters(model=Notifications, query=q, field=field)
|
|
||||||
|
|
||||||
since_id = query_args.pop("since_id", None)
|
|
||||||
if since_id:
|
|
||||||
filters.append((Notifications.id > since_id))
|
|
||||||
|
|
||||||
notifications = (
|
|
||||||
Notifications.query.filter_by(**query_args).filter(*filters).all()
|
|
||||||
)
|
|
||||||
schema = NotificationSchema(many=True)
|
|
||||||
result = schema.dump(notifications)
|
|
||||||
if result.errors:
|
|
||||||
return {"success": False, "errors": result.errors}, 400
|
|
||||||
return {"success": True, "data": result.data}
|
|
||||||
|
|
||||||
@notifications_namespace.doc(
|
|
||||||
description="Endpoint to get statistics for notification objects in bulk",
|
|
||||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
|
||||||
)
|
|
||||||
@validate_args(
|
|
||||||
{
|
|
||||||
"title": (str, None),
|
|
||||||
"content": (str, None),
|
|
||||||
"user_id": (int, None),
|
|
||||||
"team_id": (int, None),
|
|
||||||
"q": (str, None),
|
|
||||||
"field": (
|
|
||||||
RawEnum("NotificationFields", {"title": "title", "content": "content"}),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
"since_id": (int, None),
|
|
||||||
},
|
|
||||||
location="query",
|
|
||||||
)
|
|
||||||
def head(self, query_args):
|
|
||||||
q = query_args.pop("q", None)
|
|
||||||
field = str(query_args.pop("field", None))
|
|
||||||
filters = build_model_filters(model=Notifications, query=q, field=field)
|
|
||||||
|
|
||||||
since_id = query_args.pop("since_id", None)
|
|
||||||
if since_id:
|
|
||||||
filters.append((Notifications.id > since_id))
|
|
||||||
|
|
||||||
notification_count = (
|
|
||||||
Notifications.query.filter_by(**query_args).filter(*filters).count()
|
|
||||||
)
|
|
||||||
response = make_response()
|
|
||||||
response.headers["Result-Count"] = notification_count
|
|
||||||
return response
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@notifications_namespace.doc(
|
|
||||||
description="Endpoint to create a notification object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "NotificationDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def post(self):
|
|
||||||
req = request.get_json()
|
|
||||||
|
|
||||||
schema = NotificationSchema()
|
|
||||||
result = schema.load(req)
|
|
||||||
|
|
||||||
if result.errors:
|
|
||||||
return {"success": False, "errors": result.errors}, 400
|
|
||||||
|
|
||||||
db.session.add(result.data)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
response = schema.dump(result.data)
|
|
||||||
|
|
||||||
# Grab additional settings
|
|
||||||
notif_type = req.get("type", "alert")
|
|
||||||
notif_sound = req.get("sound", True)
|
|
||||||
response.data["type"] = notif_type
|
|
||||||
response.data["sound"] = notif_sound
|
|
||||||
|
|
||||||
current_app.events_manager.publish(data=response.data, type="notification")
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
|
|
||||||
@notifications_namespace.route("/<notification_id>")
|
|
||||||
@notifications_namespace.param("notification_id", "A Notification ID")
|
|
||||||
class Notification(Resource):
|
|
||||||
@notifications_namespace.doc(
|
|
||||||
description="Endpoint to get a specific notification object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "NotificationDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def get(self, notification_id):
|
|
||||||
notif = Notifications.query.filter_by(id=notification_id).first_or_404()
|
|
||||||
schema = NotificationSchema()
|
|
||||||
response = schema.dump(notif)
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@notifications_namespace.doc(
|
|
||||||
description="Endpoint to delete a notification object",
|
|
||||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
|
||||||
)
|
|
||||||
def delete(self, notification_id):
|
|
||||||
notif = Notifications.query.filter_by(id=notification_id).first_or_404()
|
|
||||||
db.session.delete(notif)
|
|
||||||
db.session.commit()
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
from typing import List
|
|
||||||
|
|
||||||
from flask import request
|
|
||||||
from flask_restx import Namespace, Resource
|
|
||||||
|
|
||||||
from CTFd.api.v1.helpers.request import validate_args
|
|
||||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
|
||||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
|
||||||
from CTFd.cache import clear_pages
|
|
||||||
from CTFd.constants import RawEnum
|
|
||||||
from CTFd.models import Pages, db
|
|
||||||
from CTFd.schemas.pages import PageSchema
|
|
||||||
from CTFd.utils.decorators import admins_only
|
|
||||||
from CTFd.utils.helpers.models import build_model_filters
|
|
||||||
|
|
||||||
pages_namespace = Namespace("pages", description="Endpoint to retrieve Pages")
|
|
||||||
|
|
||||||
|
|
||||||
PageModel = sqlalchemy_to_pydantic(Pages)
|
|
||||||
TransientPageModel = sqlalchemy_to_pydantic(Pages, exclude=["id"])
|
|
||||||
|
|
||||||
|
|
||||||
class PageDetailedSuccessResponse(APIDetailedSuccessResponse):
|
|
||||||
data: PageModel
|
|
||||||
|
|
||||||
|
|
||||||
class PageListSuccessResponse(APIListSuccessResponse):
|
|
||||||
data: List[PageModel]
|
|
||||||
|
|
||||||
|
|
||||||
pages_namespace.schema_model(
|
|
||||||
"PageDetailedSuccessResponse", PageDetailedSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
pages_namespace.schema_model(
|
|
||||||
"PageListSuccessResponse", PageListSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pages_namespace.route("")
|
|
||||||
@pages_namespace.doc(
|
|
||||||
responses={200: "Success", 400: "An error occured processing your data"}
|
|
||||||
)
|
|
||||||
class PageList(Resource):
|
|
||||||
@admins_only
|
|
||||||
@pages_namespace.doc(
|
|
||||||
description="Endpoint to get page objects in bulk",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "PageListSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@validate_args(
|
|
||||||
{
|
|
||||||
"id": (int, None),
|
|
||||||
"title": (str, None),
|
|
||||||
"route": (str, None),
|
|
||||||
"draft": (bool, None),
|
|
||||||
"hidden": (bool, None),
|
|
||||||
"auth_required": (bool, None),
|
|
||||||
"q": (str, None),
|
|
||||||
"field": (
|
|
||||||
RawEnum(
|
|
||||||
"PageFields",
|
|
||||||
{"title": "title", "route": "route", "content": "content"},
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
location="query",
|
|
||||||
)
|
|
||||||
def get(self, query_args):
|
|
||||||
q = query_args.pop("q", None)
|
|
||||||
field = str(query_args.pop("field", None))
|
|
||||||
filters = build_model_filters(model=Pages, query=q, field=field)
|
|
||||||
|
|
||||||
pages = Pages.query.filter_by(**query_args).filter(*filters).all()
|
|
||||||
schema = PageSchema(exclude=["content"], many=True)
|
|
||||||
response = schema.dump(pages)
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@pages_namespace.doc(
|
|
||||||
description="Endpoint to create a page object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "PageDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@validate_args(TransientPageModel, location="json")
|
|
||||||
def post(self, json_args):
|
|
||||||
req = json_args
|
|
||||||
schema = PageSchema()
|
|
||||||
response = schema.load(req)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
db.session.add(response.data)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
response = schema.dump(response.data)
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
clear_pages()
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
|
|
||||||
@pages_namespace.route("/<page_id>")
|
|
||||||
@pages_namespace.doc(
|
|
||||||
params={"page_id": "ID of a page object"},
|
|
||||||
responses={
|
|
||||||
200: ("Success", "PageDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
class PageDetail(Resource):
|
|
||||||
@admins_only
|
|
||||||
@pages_namespace.doc(description="Endpoint to read a page object")
|
|
||||||
def get(self, page_id):
|
|
||||||
page = Pages.query.filter_by(id=page_id).first_or_404()
|
|
||||||
schema = PageSchema()
|
|
||||||
response = schema.dump(page)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@pages_namespace.doc(description="Endpoint to edit a page object")
|
|
||||||
def patch(self, page_id):
|
|
||||||
page = Pages.query.filter_by(id=page_id).first_or_404()
|
|
||||||
req = request.get_json()
|
|
||||||
|
|
||||||
schema = PageSchema(partial=True)
|
|
||||||
response = schema.load(req, instance=page, partial=True)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
response = schema.dump(response.data)
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
clear_pages()
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@pages_namespace.doc(
|
|
||||||
description="Endpoint to delete a page object",
|
|
||||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
|
||||||
)
|
|
||||||
def delete(self, page_id):
|
|
||||||
page = Pages.query.filter_by(id=page_id).first_or_404()
|
|
||||||
db.session.delete(page)
|
|
||||||
db.session.commit()
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
clear_pages()
|
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class APISimpleSuccessResponse(BaseModel):
|
|
||||||
success: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class APIDetailedSuccessResponse(APISimpleSuccessResponse):
|
|
||||||
data: Optional[Any]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def apidoc(cls):
|
|
||||||
"""
|
|
||||||
Helper to inline references from the generated schema
|
|
||||||
"""
|
|
||||||
schema = cls.schema()
|
|
||||||
|
|
||||||
try:
|
|
||||||
key = schema["properties"]["data"]["$ref"]
|
|
||||||
ref = key.split("/").pop()
|
|
||||||
definition = schema["definitions"][ref]
|
|
||||||
schema["properties"]["data"] = definition
|
|
||||||
del schema["definitions"][ref]
|
|
||||||
if bool(schema["definitions"]) is False:
|
|
||||||
del schema["definitions"]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return schema
|
|
||||||
|
|
||||||
|
|
||||||
class APIListSuccessResponse(APIDetailedSuccessResponse):
|
|
||||||
data: Optional[List[Any]]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def apidoc(cls):
|
|
||||||
"""
|
|
||||||
Helper to inline references from the generated schema
|
|
||||||
"""
|
|
||||||
schema = cls.schema()
|
|
||||||
|
|
||||||
try:
|
|
||||||
key = schema["properties"]["data"]["items"]["$ref"]
|
|
||||||
ref = key.split("/").pop()
|
|
||||||
definition = schema["definitions"][ref]
|
|
||||||
schema["properties"]["data"]["items"] = definition
|
|
||||||
del schema["definitions"][ref]
|
|
||||||
if bool(schema["definitions"]) is False:
|
|
||||||
del schema["definitions"]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return schema
|
|
||||||
|
|
||||||
|
|
||||||
class PaginatedAPIListSuccessResponse(APIListSuccessResponse):
|
|
||||||
meta: Dict[str, Any]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def apidoc(cls):
|
|
||||||
"""
|
|
||||||
Helper to inline references from the generated schema
|
|
||||||
"""
|
|
||||||
schema = cls.schema()
|
|
||||||
|
|
||||||
schema["properties"]["meta"] = {
|
|
||||||
"title": "Meta",
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"pagination": {
|
|
||||||
"title": "Pagination",
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"page": {"title": "Page", "type": "integer"},
|
|
||||||
"next": {"title": "Next", "type": "integer"},
|
|
||||||
"prev": {"title": "Prev", "type": "integer"},
|
|
||||||
"pages": {"title": "Pages", "type": "integer"},
|
|
||||||
"per_page": {"title": "Per Page", "type": "integer"},
|
|
||||||
"total": {"title": "Total", "type": "integer"},
|
|
||||||
},
|
|
||||||
"required": ["page", "next", "prev", "pages", "per_page", "total"],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["pagination"],
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
key = schema["properties"]["data"]["items"]["$ref"]
|
|
||||||
ref = key.split("/").pop()
|
|
||||||
definition = schema["definitions"][ref]
|
|
||||||
schema["properties"]["data"]["items"] = definition
|
|
||||||
del schema["definitions"][ref]
|
|
||||||
if bool(schema["definitions"]) is False:
|
|
||||||
del schema["definitions"]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return schema
|
|
||||||
|
|
||||||
|
|
||||||
class APISimpleErrorResponse(BaseModel):
|
|
||||||
success: bool = False
|
|
||||||
errors: Optional[List[str]]
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
from collections import defaultdict
|
|
||||||
|
|
||||||
from flask_restx import Namespace, Resource
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from CTFd.cache import cache, make_cache_key
|
|
||||||
from CTFd.models import Awards, Solves, Users, db
|
|
||||||
from CTFd.utils import get_config
|
|
||||||
from CTFd.utils.dates import isoformat, unix_time_to_utc
|
|
||||||
from CTFd.utils.decorators.visibility import (
|
|
||||||
check_account_visibility,
|
|
||||||
check_score_visibility,
|
|
||||||
)
|
|
||||||
from CTFd.utils.modes import TEAMS_MODE, generate_account_url, get_mode_as_word
|
|
||||||
from CTFd.utils.scores import get_standings, get_user_standings
|
|
||||||
|
|
||||||
scoreboard_namespace = Namespace(
|
|
||||||
"scoreboard", description="Endpoint to retrieve scores"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@scoreboard_namespace.route("")
|
|
||||||
class ScoreboardList(Resource):
|
|
||||||
@check_account_visibility
|
|
||||||
@check_score_visibility
|
|
||||||
@cache.cached(timeout=60, key_prefix=make_cache_key)
|
|
||||||
def get(self):
|
|
||||||
standings = get_standings()
|
|
||||||
response = []
|
|
||||||
mode = get_config("user_mode")
|
|
||||||
account_type = get_mode_as_word()
|
|
||||||
|
|
||||||
if mode == TEAMS_MODE:
|
|
||||||
r = db.session.execute(
|
|
||||||
select(
|
|
||||||
[
|
|
||||||
Users.id,
|
|
||||||
Users.name,
|
|
||||||
Users.oauth_id,
|
|
||||||
Users.team_id,
|
|
||||||
Users.hidden,
|
|
||||||
Users.banned,
|
|
||||||
]
|
|
||||||
).where(Users.team_id.isnot(None))
|
|
||||||
)
|
|
||||||
users = r.fetchall()
|
|
||||||
membership = defaultdict(dict)
|
|
||||||
for u in users:
|
|
||||||
if u.hidden is False and u.banned is False:
|
|
||||||
membership[u.team_id][u.id] = {
|
|
||||||
"id": u.id,
|
|
||||||
"oauth_id": u.oauth_id,
|
|
||||||
"name": u.name,
|
|
||||||
"score": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get user_standings as a dict so that we can more quickly get member scores
|
|
||||||
user_standings = get_user_standings()
|
|
||||||
for u in user_standings:
|
|
||||||
membership[u.team_id][u.user_id]["score"] = int(u.score)
|
|
||||||
|
|
||||||
for i, x in enumerate(standings):
|
|
||||||
entry = {
|
|
||||||
"pos": i + 1,
|
|
||||||
"account_id": x.account_id,
|
|
||||||
"account_url": generate_account_url(account_id=x.account_id),
|
|
||||||
"account_type": account_type,
|
|
||||||
"oauth_id": x.oauth_id,
|
|
||||||
"name": x.name,
|
|
||||||
"score": int(x.score),
|
|
||||||
}
|
|
||||||
|
|
||||||
if mode == TEAMS_MODE:
|
|
||||||
entry["members"] = list(membership[x.account_id].values())
|
|
||||||
|
|
||||||
response.append(entry)
|
|
||||||
return {"success": True, "data": response}
|
|
||||||
|
|
||||||
|
|
||||||
@scoreboard_namespace.route("/top/<int:count>")
|
|
||||||
@scoreboard_namespace.param("count", "How many top teams to return")
|
|
||||||
class ScoreboardDetail(Resource):
|
|
||||||
@check_account_visibility
|
|
||||||
@check_score_visibility
|
|
||||||
@cache.cached(timeout=60, key_prefix=make_cache_key)
|
|
||||||
def get(self, count):
|
|
||||||
response = {}
|
|
||||||
|
|
||||||
standings = get_standings(count=count)
|
|
||||||
|
|
||||||
team_ids = [team.account_id for team in standings]
|
|
||||||
|
|
||||||
solves = Solves.query.filter(Solves.account_id.in_(team_ids))
|
|
||||||
awards = Awards.query.filter(Awards.account_id.in_(team_ids))
|
|
||||||
|
|
||||||
freeze = get_config("freeze")
|
|
||||||
|
|
||||||
if freeze:
|
|
||||||
solves = solves.filter(Solves.date < unix_time_to_utc(freeze))
|
|
||||||
awards = awards.filter(Awards.date < unix_time_to_utc(freeze))
|
|
||||||
|
|
||||||
solves = solves.all()
|
|
||||||
awards = awards.all()
|
|
||||||
|
|
||||||
# Build a mapping of accounts to their solves and awards
|
|
||||||
solves_mapper = defaultdict(list)
|
|
||||||
for solve in solves:
|
|
||||||
solves_mapper[solve.account_id].append(
|
|
||||||
{
|
|
||||||
"challenge_id": solve.challenge_id,
|
|
||||||
"account_id": solve.account_id,
|
|
||||||
"team_id": solve.team_id,
|
|
||||||
"user_id": solve.user_id,
|
|
||||||
"value": solve.challenge.value,
|
|
||||||
"date": isoformat(solve.date),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
for award in awards:
|
|
||||||
solves_mapper[award.account_id].append(
|
|
||||||
{
|
|
||||||
"challenge_id": None,
|
|
||||||
"account_id": award.account_id,
|
|
||||||
"team_id": award.team_id,
|
|
||||||
"user_id": award.user_id,
|
|
||||||
"value": award.value,
|
|
||||||
"date": isoformat(award.date),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Sort all solves by date
|
|
||||||
for team_id in solves_mapper:
|
|
||||||
solves_mapper[team_id] = sorted(
|
|
||||||
solves_mapper[team_id], key=lambda k: k["date"]
|
|
||||||
)
|
|
||||||
|
|
||||||
for i, _team in enumerate(team_ids):
|
|
||||||
response[i + 1] = {
|
|
||||||
"id": standings[i].account_id,
|
|
||||||
"name": standings[i].name,
|
|
||||||
"solves": solves_mapper.get(standings[i].account_id, []),
|
|
||||||
}
|
|
||||||
return {"success": True, "data": response}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
from flask_restx import Namespace
|
|
||||||
|
|
||||||
statistics_namespace = Namespace(
|
|
||||||
"statistics", description="Endpoint to retrieve Statistics"
|
|
||||||
)
|
|
||||||
|
|
||||||
# isort:imports-firstparty
|
|
||||||
from CTFd.api.v1.statistics import challenges # noqa: F401,I001
|
|
||||||
from CTFd.api.v1.statistics import scores # noqa: F401
|
|
||||||
from CTFd.api.v1.statistics import submissions # noqa: F401
|
|
||||||
from CTFd.api.v1.statistics import teams # noqa: F401
|
|
||||||
from CTFd.api.v1.statistics import users # noqa: F401
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
from flask_restx import Resource
|
|
||||||
from sqlalchemy import func
|
|
||||||
from sqlalchemy.sql import and_
|
|
||||||
|
|
||||||
from CTFd.api.v1.statistics import statistics_namespace
|
|
||||||
from CTFd.models import Challenges, Solves, db
|
|
||||||
from CTFd.utils.decorators import admins_only
|
|
||||||
from CTFd.utils.modes import get_model
|
|
||||||
|
|
||||||
|
|
||||||
@statistics_namespace.route("/challenges/<column>")
|
|
||||||
class ChallengePropertyCounts(Resource):
|
|
||||||
@admins_only
|
|
||||||
def get(self, column):
|
|
||||||
if column in Challenges.__table__.columns.keys():
|
|
||||||
prop = getattr(Challenges, column)
|
|
||||||
data = (
|
|
||||||
Challenges.query.with_entities(prop, func.count(prop))
|
|
||||||
.group_by(prop)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
return {"success": True, "data": dict(data)}
|
|
||||||
else:
|
|
||||||
response = {"message": "That could not be found"}, 404
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@statistics_namespace.route("/challenges/solves")
|
|
||||||
class ChallengeSolveStatistics(Resource):
|
|
||||||
@admins_only
|
|
||||||
def get(self):
|
|
||||||
chals = (
|
|
||||||
Challenges.query.filter(
|
|
||||||
and_(Challenges.state != "hidden", Challenges.state != "locked")
|
|
||||||
)
|
|
||||||
.order_by(Challenges.value)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
Model = get_model()
|
|
||||||
|
|
||||||
solves_sub = (
|
|
||||||
db.session.query(
|
|
||||||
Solves.challenge_id, db.func.count(Solves.challenge_id).label("solves")
|
|
||||||
)
|
|
||||||
.join(Model, Solves.account_id == Model.id)
|
|
||||||
.filter(Model.banned == False, Model.hidden == False)
|
|
||||||
.group_by(Solves.challenge_id)
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
|
|
||||||
solves = (
|
|
||||||
db.session.query(
|
|
||||||
solves_sub.columns.challenge_id,
|
|
||||||
solves_sub.columns.solves,
|
|
||||||
Challenges.name,
|
|
||||||
)
|
|
||||||
.join(Challenges, solves_sub.columns.challenge_id == Challenges.id)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
response = []
|
|
||||||
has_solves = []
|
|
||||||
|
|
||||||
for challenge_id, count, name in solves:
|
|
||||||
challenge = {"id": challenge_id, "name": name, "solves": count}
|
|
||||||
response.append(challenge)
|
|
||||||
has_solves.append(challenge_id)
|
|
||||||
for c in chals:
|
|
||||||
if c.id not in has_solves:
|
|
||||||
challenge = {"id": c.id, "name": c.name, "solves": 0}
|
|
||||||
response.append(challenge)
|
|
||||||
|
|
||||||
db.session.close()
|
|
||||||
return {"success": True, "data": response}
|
|
||||||
|
|
||||||
|
|
||||||
@statistics_namespace.route("/challenges/solves/percentages")
|
|
||||||
class ChallengeSolvePercentages(Resource):
|
|
||||||
@admins_only
|
|
||||||
def get(self):
|
|
||||||
challenges = (
|
|
||||||
Challenges.query.add_columns("id", "name", "state", "max_attempts")
|
|
||||||
.order_by(Challenges.value)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
Model = get_model()
|
|
||||||
|
|
||||||
teams_with_points = (
|
|
||||||
db.session.query(Solves.account_id)
|
|
||||||
.join(Model)
|
|
||||||
.filter(Model.banned == False, Model.hidden == False)
|
|
||||||
.group_by(Solves.account_id)
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
percentage_data = []
|
|
||||||
for challenge in challenges:
|
|
||||||
solve_count = (
|
|
||||||
Solves.query.join(Model, Solves.account_id == Model.id)
|
|
||||||
.filter(
|
|
||||||
Solves.challenge_id == challenge.id,
|
|
||||||
Model.banned == False,
|
|
||||||
Model.hidden == False,
|
|
||||||
)
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
if teams_with_points > 0:
|
|
||||||
percentage = float(solve_count) / float(teams_with_points)
|
|
||||||
else:
|
|
||||||
percentage = 0.0
|
|
||||||
|
|
||||||
percentage_data.append(
|
|
||||||
{"id": challenge.id, "name": challenge.name, "percentage": percentage}
|
|
||||||
)
|
|
||||||
|
|
||||||
response = sorted(percentage_data, key=lambda x: x["percentage"], reverse=True)
|
|
||||||
return {"success": True, "data": response}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
from collections import defaultdict
|
|
||||||
|
|
||||||
from flask_restx import Resource
|
|
||||||
|
|
||||||
from CTFd.api.v1.statistics import statistics_namespace
|
|
||||||
from CTFd.models import Challenges, db
|
|
||||||
from CTFd.utils.decorators import admins_only
|
|
||||||
from CTFd.utils.scores import get_standings
|
|
||||||
|
|
||||||
|
|
||||||
@statistics_namespace.route("/scores/distribution")
|
|
||||||
class ScoresDistribution(Resource):
|
|
||||||
@admins_only
|
|
||||||
def get(self):
|
|
||||||
challenge_count = Challenges.query.count() or 1
|
|
||||||
total_points = (
|
|
||||||
Challenges.query.with_entities(db.func.sum(Challenges.value).label("sum"))
|
|
||||||
.filter_by(state="visible")
|
|
||||||
.first()
|
|
||||||
.sum
|
|
||||||
) or 0
|
|
||||||
# Convert Decimal() to int in some database backends for Python 2
|
|
||||||
total_points = int(total_points)
|
|
||||||
|
|
||||||
# Divide score by challenges to get brackets with explicit floor division
|
|
||||||
bracket_size = total_points // challenge_count
|
|
||||||
|
|
||||||
# Get standings
|
|
||||||
standings = get_standings(admin=True)
|
|
||||||
|
|
||||||
# Iterate over standings and increment the count for each bracket for each standing within that bracket
|
|
||||||
bottom, top = 0, bracket_size
|
|
||||||
count = 1
|
|
||||||
brackets = defaultdict(lambda: 0)
|
|
||||||
for t in reversed(standings):
|
|
||||||
if ((t.score >= bottom) and (t.score <= top)) or t.score <= 0:
|
|
||||||
brackets[top] += 1
|
|
||||||
else:
|
|
||||||
count += 1
|
|
||||||
bottom, top = (bracket_size, (bracket_size * count))
|
|
||||||
brackets[top] += 1
|
|
||||||
|
|
||||||
return {"success": True, "data": {"brackets": brackets}}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
from flask_restx import Resource
|
|
||||||
from sqlalchemy import func
|
|
||||||
|
|
||||||
from CTFd.api.v1.statistics import statistics_namespace
|
|
||||||
from CTFd.models import Submissions
|
|
||||||
from CTFd.utils.decorators import admins_only
|
|
||||||
|
|
||||||
|
|
||||||
@statistics_namespace.route("/submissions/<column>")
|
|
||||||
class SubmissionPropertyCounts(Resource):
|
|
||||||
@admins_only
|
|
||||||
def get(self, column):
|
|
||||||
if column in Submissions.__table__.columns.keys():
|
|
||||||
prop = getattr(Submissions, column)
|
|
||||||
data = (
|
|
||||||
Submissions.query.with_entities(prop, func.count(prop))
|
|
||||||
.group_by(prop)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
return {"success": True, "data": dict(data)}
|
|
||||||
else:
|
|
||||||
response = {"success": False, "errors": "That could not be found"}, 404
|
|
||||||
return response
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
from flask_restx import Resource
|
|
||||||
|
|
||||||
from CTFd.api.v1.statistics import statistics_namespace
|
|
||||||
from CTFd.models import Teams
|
|
||||||
from CTFd.utils.decorators import admins_only
|
|
||||||
|
|
||||||
|
|
||||||
@statistics_namespace.route("/teams")
|
|
||||||
class TeamStatistics(Resource):
|
|
||||||
@admins_only
|
|
||||||
def get(self):
|
|
||||||
registered = Teams.query.count()
|
|
||||||
data = {"registered": registered}
|
|
||||||
return {"success": True, "data": data}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
from flask_restx import Resource
|
|
||||||
from sqlalchemy import func
|
|
||||||
|
|
||||||
from CTFd.api.v1.statistics import statistics_namespace
|
|
||||||
from CTFd.models import Users
|
|
||||||
from CTFd.utils.decorators import admins_only
|
|
||||||
|
|
||||||
|
|
||||||
@statistics_namespace.route("/users")
|
|
||||||
class UserStatistics(Resource):
|
|
||||||
@admins_only
|
|
||||||
def get(self):
|
|
||||||
registered = Users.query.count()
|
|
||||||
confirmed = Users.query.filter_by(verified=True).count()
|
|
||||||
data = {"registered": registered, "confirmed": confirmed}
|
|
||||||
return {"success": True, "data": data}
|
|
||||||
|
|
||||||
|
|
||||||
@statistics_namespace.route("/users/<column>")
|
|
||||||
class UserPropertyCounts(Resource):
|
|
||||||
@admins_only
|
|
||||||
def get(self, column):
|
|
||||||
if column in Users.__table__.columns.keys():
|
|
||||||
prop = getattr(Users, column)
|
|
||||||
data = (
|
|
||||||
Users.query.with_entities(prop, func.count(prop)).group_by(prop).all()
|
|
||||||
)
|
|
||||||
return {"success": True, "data": dict(data)}
|
|
||||||
else:
|
|
||||||
return {"success": False, "message": "That could not be found"}, 404
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
from typing import List
|
|
||||||
|
|
||||||
from flask_restx import Namespace, Resource
|
|
||||||
|
|
||||||
from CTFd.api.v1.helpers.request import validate_args
|
|
||||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
|
||||||
from CTFd.api.v1.schemas import (
|
|
||||||
APIDetailedSuccessResponse,
|
|
||||||
PaginatedAPIListSuccessResponse,
|
|
||||||
)
|
|
||||||
from CTFd.cache import clear_challenges, clear_standings
|
|
||||||
from CTFd.constants import RawEnum
|
|
||||||
from CTFd.models import Submissions, db
|
|
||||||
from CTFd.schemas.submissions import SubmissionSchema
|
|
||||||
from CTFd.utils.decorators import admins_only
|
|
||||||
from CTFd.utils.helpers.models import build_model_filters
|
|
||||||
|
|
||||||
submissions_namespace = Namespace(
|
|
||||||
"submissions", description="Endpoint to retrieve Submission"
|
|
||||||
)
|
|
||||||
|
|
||||||
SubmissionModel = sqlalchemy_to_pydantic(Submissions)
|
|
||||||
TransientSubmissionModel = sqlalchemy_to_pydantic(Submissions, exclude=["id"])
|
|
||||||
|
|
||||||
|
|
||||||
class SubmissionDetailedSuccessResponse(APIDetailedSuccessResponse):
|
|
||||||
data: SubmissionModel
|
|
||||||
|
|
||||||
|
|
||||||
class SubmissionListSuccessResponse(PaginatedAPIListSuccessResponse):
|
|
||||||
data: List[SubmissionModel]
|
|
||||||
|
|
||||||
|
|
||||||
submissions_namespace.schema_model(
|
|
||||||
"SubmissionDetailedSuccessResponse", SubmissionDetailedSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
submissions_namespace.schema_model(
|
|
||||||
"SubmissionListSuccessResponse", SubmissionListSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@submissions_namespace.route("")
|
|
||||||
class SubmissionsList(Resource):
|
|
||||||
@admins_only
|
|
||||||
@submissions_namespace.doc(
|
|
||||||
description="Endpoint to get submission objects in bulk",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "SubmissionListSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@validate_args(
|
|
||||||
{
|
|
||||||
"challenge_id": (int, None),
|
|
||||||
"user_id": (int, None),
|
|
||||||
"team_id": (int, None),
|
|
||||||
"ip": (str, None),
|
|
||||||
"provided": (str, None),
|
|
||||||
"type": (str, None),
|
|
||||||
"q": (str, None),
|
|
||||||
"field": (
|
|
||||||
RawEnum(
|
|
||||||
"SubmissionFields",
|
|
||||||
{
|
|
||||||
"challenge_id": "challenge_id",
|
|
||||||
"user_id": "user_id",
|
|
||||||
"team_id": "team_id",
|
|
||||||
"ip": "ip",
|
|
||||||
"provided": "provided",
|
|
||||||
"type": "type",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
location="query",
|
|
||||||
)
|
|
||||||
def get(self, query_args):
|
|
||||||
q = query_args.pop("q", None)
|
|
||||||
field = str(query_args.pop("field", None))
|
|
||||||
filters = build_model_filters(model=Submissions, query=q, field=field)
|
|
||||||
|
|
||||||
args = query_args
|
|
||||||
schema = SubmissionSchema(many=True)
|
|
||||||
|
|
||||||
submissions = (
|
|
||||||
Submissions.query.filter_by(**args)
|
|
||||||
.filter(*filters)
|
|
||||||
.paginate(max_per_page=100)
|
|
||||||
)
|
|
||||||
|
|
||||||
response = schema.dump(submissions.items)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {
|
|
||||||
"meta": {
|
|
||||||
"pagination": {
|
|
||||||
"page": submissions.page,
|
|
||||||
"next": submissions.next_num,
|
|
||||||
"prev": submissions.prev_num,
|
|
||||||
"pages": submissions.pages,
|
|
||||||
"per_page": submissions.per_page,
|
|
||||||
"total": submissions.total,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"success": True,
|
|
||||||
"data": response.data,
|
|
||||||
}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@submissions_namespace.doc(
|
|
||||||
description="Endpoint to create a submission object. Users should interact with the attempt endpoint to submit flags.",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "SubmissionListSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@validate_args(TransientSubmissionModel, location="json")
|
|
||||||
def post(self, json_args):
|
|
||||||
req = json_args
|
|
||||||
Model = Submissions.get_child(type=req.get("type"))
|
|
||||||
schema = SubmissionSchema(instance=Model())
|
|
||||||
response = schema.load(req)
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
db.session.add(response.data)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
response = schema.dump(response.data)
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
# Delete standings cache
|
|
||||||
clear_standings()
|
|
||||||
# Delete challenges cache
|
|
||||||
clear_challenges()
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
|
|
||||||
@submissions_namespace.route("/<submission_id>")
|
|
||||||
@submissions_namespace.param("submission_id", "A Submission ID")
|
|
||||||
class Submission(Resource):
|
|
||||||
@admins_only
|
|
||||||
@submissions_namespace.doc(
|
|
||||||
description="Endpoint to get submission objects in bulk",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "SubmissionDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def get(self, submission_id):
|
|
||||||
submission = Submissions.query.filter_by(id=submission_id).first_or_404()
|
|
||||||
schema = SubmissionSchema()
|
|
||||||
response = schema.dump(submission)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@submissions_namespace.doc(
|
|
||||||
description="Endpoint to get submission objects in bulk",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "APISimpleSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def delete(self, submission_id):
|
|
||||||
submission = Submissions.query.filter_by(id=submission_id).first_or_404()
|
|
||||||
db.session.delete(submission)
|
|
||||||
db.session.commit()
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
# Delete standings cache
|
|
||||||
clear_standings()
|
|
||||||
clear_challenges()
|
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
from typing import List
|
|
||||||
|
|
||||||
from flask import request
|
|
||||||
from flask_restx import Namespace, Resource
|
|
||||||
|
|
||||||
from CTFd.api.v1.helpers.request import validate_args
|
|
||||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
|
||||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
|
||||||
from CTFd.constants import RawEnum
|
|
||||||
from CTFd.models import Tags, db
|
|
||||||
from CTFd.schemas.tags import TagSchema
|
|
||||||
from CTFd.utils.decorators import admins_only
|
|
||||||
from CTFd.utils.helpers.models import build_model_filters
|
|
||||||
|
|
||||||
tags_namespace = Namespace("tags", description="Endpoint to retrieve Tags")
|
|
||||||
|
|
||||||
TagModel = sqlalchemy_to_pydantic(Tags)
|
|
||||||
|
|
||||||
|
|
||||||
class TagDetailedSuccessResponse(APIDetailedSuccessResponse):
|
|
||||||
data: TagModel
|
|
||||||
|
|
||||||
|
|
||||||
class TagListSuccessResponse(APIListSuccessResponse):
|
|
||||||
data: List[TagModel]
|
|
||||||
|
|
||||||
|
|
||||||
tags_namespace.schema_model(
|
|
||||||
"TagDetailedSuccessResponse", TagDetailedSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
tags_namespace.schema_model("TagListSuccessResponse", TagListSuccessResponse.apidoc())
|
|
||||||
|
|
||||||
|
|
||||||
@tags_namespace.route("")
|
|
||||||
class TagList(Resource):
|
|
||||||
@admins_only
|
|
||||||
@tags_namespace.doc(
|
|
||||||
description="Endpoint to list Tag objects in bulk",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "TagListSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@validate_args(
|
|
||||||
{
|
|
||||||
"challenge_id": (int, None),
|
|
||||||
"value": (str, None),
|
|
||||||
"q": (str, None),
|
|
||||||
"field": (
|
|
||||||
RawEnum(
|
|
||||||
"TagFields", {"challenge_id": "challenge_id", "value": "value"}
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
location="query",
|
|
||||||
)
|
|
||||||
def get(self, query_args):
|
|
||||||
q = query_args.pop("q", None)
|
|
||||||
field = str(query_args.pop("field", None))
|
|
||||||
filters = build_model_filters(model=Tags, query=q, field=field)
|
|
||||||
|
|
||||||
tags = Tags.query.filter_by(**query_args).filter(*filters).all()
|
|
||||||
schema = TagSchema(many=True)
|
|
||||||
response = schema.dump(tags)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@tags_namespace.doc(
|
|
||||||
description="Endpoint to create a Tag object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "TagDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def post(self):
|
|
||||||
req = request.get_json()
|
|
||||||
schema = TagSchema()
|
|
||||||
response = schema.load(req, session=db.session)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
db.session.add(response.data)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
response = schema.dump(response.data)
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
|
|
||||||
@tags_namespace.route("/<tag_id>")
|
|
||||||
@tags_namespace.param("tag_id", "A Tag ID")
|
|
||||||
class Tag(Resource):
|
|
||||||
@admins_only
|
|
||||||
@tags_namespace.doc(
|
|
||||||
description="Endpoint to get a specific Tag object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "TagDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def get(self, tag_id):
|
|
||||||
tag = Tags.query.filter_by(id=tag_id).first_or_404()
|
|
||||||
|
|
||||||
response = TagSchema().dump(tag)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@tags_namespace.doc(
|
|
||||||
description="Endpoint to edit a specific Tag object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "TagDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def patch(self, tag_id):
|
|
||||||
tag = Tags.query.filter_by(id=tag_id).first_or_404()
|
|
||||||
schema = TagSchema()
|
|
||||||
req = request.get_json()
|
|
||||||
|
|
||||||
response = schema.load(req, session=db.session, instance=tag)
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
response = schema.dump(response.data)
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@tags_namespace.doc(
|
|
||||||
description="Endpoint to delete a specific Tag object",
|
|
||||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
|
||||||
)
|
|
||||||
def delete(self, tag_id):
|
|
||||||
tag = Tags.query.filter_by(id=tag_id).first_or_404()
|
|
||||||
db.session.delete(tag)
|
|
||||||
db.session.commit()
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
@@ -1,657 +0,0 @@
|
|||||||
import copy
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from flask import abort, request, session
|
|
||||||
from flask_restx import Namespace, Resource
|
|
||||||
|
|
||||||
from CTFd.api.v1.helpers.request import validate_args
|
|
||||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
|
||||||
from CTFd.api.v1.schemas import (
|
|
||||||
APIDetailedSuccessResponse,
|
|
||||||
PaginatedAPIListSuccessResponse,
|
|
||||||
)
|
|
||||||
from CTFd.cache import (
|
|
||||||
clear_challenges,
|
|
||||||
clear_standings,
|
|
||||||
clear_team_session,
|
|
||||||
clear_user_session,
|
|
||||||
)
|
|
||||||
from CTFd.constants import RawEnum
|
|
||||||
from CTFd.models import Awards, Submissions, Teams, Unlocks, Users, db
|
|
||||||
from CTFd.schemas.awards import AwardSchema
|
|
||||||
from CTFd.schemas.submissions import SubmissionSchema
|
|
||||||
from CTFd.schemas.teams import TeamSchema
|
|
||||||
from CTFd.utils import get_config
|
|
||||||
from CTFd.utils.decorators import admins_only, authed_only, require_team
|
|
||||||
from CTFd.utils.decorators.modes import require_team_mode
|
|
||||||
from CTFd.utils.decorators.visibility import (
|
|
||||||
check_account_visibility,
|
|
||||||
check_score_visibility,
|
|
||||||
)
|
|
||||||
from CTFd.utils.helpers.models import build_model_filters
|
|
||||||
from CTFd.utils.user import get_current_team, get_current_user_type, is_admin
|
|
||||||
|
|
||||||
teams_namespace = Namespace("teams", description="Endpoint to retrieve Teams")
|
|
||||||
|
|
||||||
TeamModel = sqlalchemy_to_pydantic(Teams)
|
|
||||||
TransientTeamModel = sqlalchemy_to_pydantic(Teams, exclude=["id"])
|
|
||||||
|
|
||||||
|
|
||||||
class TeamDetailedSuccessResponse(APIDetailedSuccessResponse):
|
|
||||||
data: TeamModel
|
|
||||||
|
|
||||||
|
|
||||||
class TeamListSuccessResponse(PaginatedAPIListSuccessResponse):
|
|
||||||
data: List[TeamModel]
|
|
||||||
|
|
||||||
|
|
||||||
teams_namespace.schema_model(
|
|
||||||
"TeamDetailedSuccessResponse", TeamDetailedSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
teams_namespace.schema_model(
|
|
||||||
"TeamListSuccessResponse", TeamListSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@teams_namespace.route("")
|
|
||||||
class TeamList(Resource):
|
|
||||||
method_decorators = [require_team_mode]
|
|
||||||
|
|
||||||
@check_account_visibility
|
|
||||||
@teams_namespace.doc(
|
|
||||||
description="Endpoint to get Team objects in bulk",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "TeamListSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@validate_args(
|
|
||||||
{
|
|
||||||
"affiliation": (str, None),
|
|
||||||
"country": (str, None),
|
|
||||||
"bracket": (str, None),
|
|
||||||
"q": (str, None),
|
|
||||||
"field": (
|
|
||||||
RawEnum(
|
|
||||||
"TeamFields",
|
|
||||||
{
|
|
||||||
"name": "name",
|
|
||||||
"website": "website",
|
|
||||||
"country": "country",
|
|
||||||
"bracket": "bracket",
|
|
||||||
"affiliation": "affiliation",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
location="query",
|
|
||||||
)
|
|
||||||
def get(self, query_args):
|
|
||||||
q = query_args.pop("q", None)
|
|
||||||
field = str(query_args.pop("field", None))
|
|
||||||
filters = build_model_filters(model=Teams, query=q, field=field)
|
|
||||||
|
|
||||||
if is_admin() and request.args.get("view") == "admin":
|
|
||||||
teams = (
|
|
||||||
Teams.query.filter_by(**query_args)
|
|
||||||
.filter(*filters)
|
|
||||||
.paginate(per_page=50, max_per_page=100)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
teams = (
|
|
||||||
Teams.query.filter_by(hidden=False, banned=False, **query_args)
|
|
||||||
.filter(*filters)
|
|
||||||
.paginate(per_page=50, max_per_page=100)
|
|
||||||
)
|
|
||||||
|
|
||||||
user_type = get_current_user_type(fallback="user")
|
|
||||||
view = copy.deepcopy(TeamSchema.views.get(user_type))
|
|
||||||
view.remove("members")
|
|
||||||
response = TeamSchema(view=view, many=True).dump(teams.items)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {
|
|
||||||
"meta": {
|
|
||||||
"pagination": {
|
|
||||||
"page": teams.page,
|
|
||||||
"next": teams.next_num,
|
|
||||||
"prev": teams.prev_num,
|
|
||||||
"pages": teams.pages,
|
|
||||||
"per_page": teams.per_page,
|
|
||||||
"total": teams.total,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"success": True,
|
|
||||||
"data": response.data,
|
|
||||||
}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@teams_namespace.doc(
|
|
||||||
description="Endpoint to create a Team object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "TeamDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def post(self):
|
|
||||||
req = request.get_json()
|
|
||||||
user_type = get_current_user_type()
|
|
||||||
view = TeamSchema.views.get(user_type)
|
|
||||||
schema = TeamSchema(view=view)
|
|
||||||
response = schema.load(req)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
db.session.add(response.data)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
response = schema.dump(response.data)
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
clear_standings()
|
|
||||||
clear_challenges()
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
|
|
||||||
@teams_namespace.route("/<int:team_id>")
|
|
||||||
@teams_namespace.param("team_id", "Team ID")
|
|
||||||
class TeamPublic(Resource):
|
|
||||||
method_decorators = [require_team_mode]
|
|
||||||
|
|
||||||
@check_account_visibility
|
|
||||||
@teams_namespace.doc(
|
|
||||||
description="Endpoint to get a specific Team object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "TeamDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def get(self, team_id):
|
|
||||||
team = Teams.query.filter_by(id=team_id).first_or_404()
|
|
||||||
|
|
||||||
if (team.banned or team.hidden) and is_admin() is False:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
user_type = get_current_user_type(fallback="user")
|
|
||||||
view = TeamSchema.views.get(user_type)
|
|
||||||
schema = TeamSchema(view=view)
|
|
||||||
response = schema.dump(team)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
response.data["place"] = team.place
|
|
||||||
response.data["score"] = team.score
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@teams_namespace.doc(
|
|
||||||
description="Endpoint to edit a specific Team object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "TeamDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def patch(self, team_id):
|
|
||||||
team = Teams.query.filter_by(id=team_id).first_or_404()
|
|
||||||
data = request.get_json()
|
|
||||||
data["id"] = team_id
|
|
||||||
|
|
||||||
schema = TeamSchema(view="admin", instance=team, partial=True)
|
|
||||||
|
|
||||||
response = schema.load(data)
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
response = schema.dump(response.data)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
clear_team_session(team_id=team.id)
|
|
||||||
clear_standings()
|
|
||||||
clear_challenges()
|
|
||||||
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@teams_namespace.doc(
|
|
||||||
description="Endpoint to delete a specific Team object",
|
|
||||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
|
||||||
)
|
|
||||||
def delete(self, team_id):
|
|
||||||
team = Teams.query.filter_by(id=team_id).first_or_404()
|
|
||||||
team_id = team.id
|
|
||||||
|
|
||||||
for member in team.members:
|
|
||||||
member.team_id = None
|
|
||||||
clear_user_session(user_id=member.id)
|
|
||||||
|
|
||||||
db.session.delete(team)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
clear_team_session(team_id=team_id)
|
|
||||||
clear_standings()
|
|
||||||
clear_challenges()
|
|
||||||
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
|
|
||||||
@teams_namespace.route("/me")
|
|
||||||
@teams_namespace.param("team_id", "Current Team")
|
|
||||||
class TeamPrivate(Resource):
|
|
||||||
method_decorators = [require_team_mode]
|
|
||||||
|
|
||||||
@authed_only
|
|
||||||
@require_team
|
|
||||||
@teams_namespace.doc(
|
|
||||||
description="Endpoint to get the current user's Team object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "TeamDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def get(self):
|
|
||||||
team = get_current_team()
|
|
||||||
response = TeamSchema(view="self").dump(team)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
response.data["place"] = team.place
|
|
||||||
response.data["score"] = team.score
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@authed_only
|
|
||||||
@require_team
|
|
||||||
@teams_namespace.doc(
|
|
||||||
description="Endpoint to edit the current user's Team object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "TeamDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def patch(self):
|
|
||||||
team = get_current_team()
|
|
||||||
if team.captain_id != session["id"]:
|
|
||||||
return (
|
|
||||||
{
|
|
||||||
"success": False,
|
|
||||||
"errors": {"": ["Only team captains can edit team information"]},
|
|
||||||
},
|
|
||||||
403,
|
|
||||||
)
|
|
||||||
|
|
||||||
data = request.get_json()
|
|
||||||
|
|
||||||
response = TeamSchema(view="self", instance=team, partial=True).load(data)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
clear_team_session(team_id=team.id)
|
|
||||||
response = TeamSchema("self").dump(response.data)
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@authed_only
|
|
||||||
@require_team
|
|
||||||
@teams_namespace.doc(
|
|
||||||
description="Endpoint to disband your current team. Can only be used if the team has performed no actions in the CTF.",
|
|
||||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
|
||||||
)
|
|
||||||
def delete(self):
|
|
||||||
team_disbanding = get_config("team_disbanding", default="inactive_only")
|
|
||||||
if team_disbanding == "disabled":
|
|
||||||
return (
|
|
||||||
{
|
|
||||||
"success": False,
|
|
||||||
"errors": {"": ["Team disbanding is currently disabled"]},
|
|
||||||
},
|
|
||||||
403,
|
|
||||||
)
|
|
||||||
|
|
||||||
team = get_current_team()
|
|
||||||
if team.captain_id != session["id"]:
|
|
||||||
return (
|
|
||||||
{
|
|
||||||
"success": False,
|
|
||||||
"errors": {"": ["Only team captains can disband their team"]},
|
|
||||||
},
|
|
||||||
403,
|
|
||||||
)
|
|
||||||
|
|
||||||
# The team must not have performed any actions in the CTF
|
|
||||||
performed_actions = any(
|
|
||||||
[
|
|
||||||
team.solves != [],
|
|
||||||
team.fails != [],
|
|
||||||
team.awards != [],
|
|
||||||
Submissions.query.filter_by(team_id=team.id).all() != [],
|
|
||||||
Unlocks.query.filter_by(team_id=team.id).all() != [],
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
if performed_actions:
|
|
||||||
return (
|
|
||||||
{
|
|
||||||
"success": False,
|
|
||||||
"errors": {
|
|
||||||
"": [
|
|
||||||
"You cannot disband your team as it has participated in the event. "
|
|
||||||
"Please contact an admin to disband your team or remove a member."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
},
|
|
||||||
403,
|
|
||||||
)
|
|
||||||
|
|
||||||
for member in team.members:
|
|
||||||
member.team_id = None
|
|
||||||
clear_user_session(user_id=member.id)
|
|
||||||
|
|
||||||
db.session.delete(team)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
clear_team_session(team_id=team.id)
|
|
||||||
clear_standings()
|
|
||||||
clear_challenges()
|
|
||||||
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
|
|
||||||
@teams_namespace.route("/me/members")
|
|
||||||
class TeamPrivateMembers(Resource):
|
|
||||||
method_decorators = [require_team_mode]
|
|
||||||
|
|
||||||
@authed_only
|
|
||||||
@require_team
|
|
||||||
def post(self):
|
|
||||||
team = get_current_team()
|
|
||||||
if team.captain_id != session["id"]:
|
|
||||||
return (
|
|
||||||
{
|
|
||||||
"success": False,
|
|
||||||
"errors": {"": ["Only team captains can generate invite codes"]},
|
|
||||||
},
|
|
||||||
403,
|
|
||||||
)
|
|
||||||
|
|
||||||
invite_code = team.get_invite_code()
|
|
||||||
response = {"code": invite_code}
|
|
||||||
return {"success": True, "data": response}
|
|
||||||
|
|
||||||
|
|
||||||
@teams_namespace.route("/<team_id>/members")
|
|
||||||
@teams_namespace.param("team_id", "Team ID")
|
|
||||||
class TeamMembers(Resource):
|
|
||||||
method_decorators = [require_team_mode]
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
def get(self, team_id):
|
|
||||||
team = Teams.query.filter_by(id=team_id).first_or_404()
|
|
||||||
|
|
||||||
view = "admin" if is_admin() else "user"
|
|
||||||
schema = TeamSchema(view=view)
|
|
||||||
response = schema.dump(team)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
members = response.data.get("members")
|
|
||||||
|
|
||||||
return {"success": True, "data": members}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
def post(self, team_id):
|
|
||||||
team = Teams.query.filter_by(id=team_id).first_or_404()
|
|
||||||
|
|
||||||
# Generate an invite code if no user or body is specified
|
|
||||||
if len(request.data) == 0:
|
|
||||||
invite_code = team.get_invite_code()
|
|
||||||
response = {"code": invite_code}
|
|
||||||
return {"success": True, "data": response}
|
|
||||||
|
|
||||||
data = request.get_json()
|
|
||||||
user_id = data.get("user_id")
|
|
||||||
user = Users.query.filter_by(id=user_id).first_or_404()
|
|
||||||
if user.team_id is None:
|
|
||||||
team.members.append(user)
|
|
||||||
db.session.commit()
|
|
||||||
else:
|
|
||||||
return (
|
|
||||||
{
|
|
||||||
"success": False,
|
|
||||||
"errors": {"id": ["User has already joined a team"]},
|
|
||||||
},
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
|
|
||||||
view = "admin" if is_admin() else "user"
|
|
||||||
schema = TeamSchema(view=view)
|
|
||||||
response = schema.dump(team)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
members = response.data.get("members")
|
|
||||||
|
|
||||||
return {"success": True, "data": members}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
def delete(self, team_id):
|
|
||||||
team = Teams.query.filter_by(id=team_id).first_or_404()
|
|
||||||
|
|
||||||
data = request.get_json()
|
|
||||||
user_id = data["user_id"]
|
|
||||||
user = Users.query.filter_by(id=user_id).first_or_404()
|
|
||||||
|
|
||||||
if user.team_id == team.id:
|
|
||||||
team.members.remove(user)
|
|
||||||
|
|
||||||
# Remove information that links the user to the team
|
|
||||||
Submissions.query.filter_by(user_id=user.id).delete()
|
|
||||||
Awards.query.filter_by(user_id=user.id).delete()
|
|
||||||
Unlocks.query.filter_by(user_id=user.id).delete()
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
else:
|
|
||||||
return (
|
|
||||||
{"success": False, "errors": {"id": ["User is not part of this team"]}},
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
|
|
||||||
view = "admin" if is_admin() else "user"
|
|
||||||
schema = TeamSchema(view=view)
|
|
||||||
response = schema.dump(team)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
members = response.data.get("members")
|
|
||||||
|
|
||||||
return {"success": True, "data": members}
|
|
||||||
|
|
||||||
|
|
||||||
@teams_namespace.route("/me/solves")
|
|
||||||
class TeamPrivateSolves(Resource):
|
|
||||||
method_decorators = [require_team_mode]
|
|
||||||
|
|
||||||
@authed_only
|
|
||||||
@require_team
|
|
||||||
def get(self):
|
|
||||||
team = get_current_team()
|
|
||||||
solves = team.get_solves(admin=True)
|
|
||||||
|
|
||||||
view = "admin" if is_admin() else "user"
|
|
||||||
schema = SubmissionSchema(view=view, many=True)
|
|
||||||
response = schema.dump(solves)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
count = len(response.data)
|
|
||||||
return {"success": True, "data": response.data, "meta": {"count": count}}
|
|
||||||
|
|
||||||
|
|
||||||
@teams_namespace.route("/me/fails")
|
|
||||||
class TeamPrivateFails(Resource):
|
|
||||||
method_decorators = [require_team_mode]
|
|
||||||
|
|
||||||
@authed_only
|
|
||||||
@require_team
|
|
||||||
def get(self):
|
|
||||||
team = get_current_team()
|
|
||||||
fails = team.get_fails(admin=True)
|
|
||||||
|
|
||||||
view = "admin" if is_admin() else "user"
|
|
||||||
|
|
||||||
# We want to return the count purely for stats & graphs
|
|
||||||
# but this data isn't really needed by the end user.
|
|
||||||
# Only actually show fail data for admins.
|
|
||||||
if is_admin():
|
|
||||||
schema = SubmissionSchema(view=view, many=True)
|
|
||||||
response = schema.dump(fails)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
data = response.data
|
|
||||||
else:
|
|
||||||
data = []
|
|
||||||
count = len(fails)
|
|
||||||
|
|
||||||
return {"success": True, "data": data, "meta": {"count": count}}
|
|
||||||
|
|
||||||
|
|
||||||
@teams_namespace.route("/me/awards")
|
|
||||||
class TeamPrivateAwards(Resource):
|
|
||||||
method_decorators = [require_team_mode]
|
|
||||||
|
|
||||||
@authed_only
|
|
||||||
@require_team
|
|
||||||
def get(self):
|
|
||||||
team = get_current_team()
|
|
||||||
awards = team.get_awards(admin=True)
|
|
||||||
|
|
||||||
schema = AwardSchema(many=True)
|
|
||||||
response = schema.dump(awards)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
count = len(response.data)
|
|
||||||
return {"success": True, "data": response.data, "meta": {"count": count}}
|
|
||||||
|
|
||||||
|
|
||||||
@teams_namespace.route("/<team_id>/solves")
|
|
||||||
@teams_namespace.param("team_id", "Team ID")
|
|
||||||
class TeamPublicSolves(Resource):
|
|
||||||
method_decorators = [require_team_mode]
|
|
||||||
|
|
||||||
@check_account_visibility
|
|
||||||
@check_score_visibility
|
|
||||||
def get(self, team_id):
|
|
||||||
team = Teams.query.filter_by(id=team_id).first_or_404()
|
|
||||||
|
|
||||||
if (team.banned or team.hidden) and is_admin() is False:
|
|
||||||
abort(404)
|
|
||||||
solves = team.get_solves(admin=is_admin())
|
|
||||||
|
|
||||||
view = "admin" if is_admin() else "user"
|
|
||||||
schema = SubmissionSchema(view=view, many=True)
|
|
||||||
response = schema.dump(solves)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
count = len(response.data)
|
|
||||||
return {"success": True, "data": response.data, "meta": {"count": count}}
|
|
||||||
|
|
||||||
|
|
||||||
@teams_namespace.route("/<team_id>/fails")
|
|
||||||
@teams_namespace.param("team_id", "Team ID")
|
|
||||||
class TeamPublicFails(Resource):
|
|
||||||
method_decorators = [require_team_mode]
|
|
||||||
|
|
||||||
@check_account_visibility
|
|
||||||
@check_score_visibility
|
|
||||||
def get(self, team_id):
|
|
||||||
team = Teams.query.filter_by(id=team_id).first_or_404()
|
|
||||||
|
|
||||||
if (team.banned or team.hidden) and is_admin() is False:
|
|
||||||
abort(404)
|
|
||||||
fails = team.get_fails(admin=is_admin())
|
|
||||||
|
|
||||||
view = "admin" if is_admin() else "user"
|
|
||||||
|
|
||||||
# We want to return the count purely for stats & graphs
|
|
||||||
# but this data isn't really needed by the end user.
|
|
||||||
# Only actually show fail data for admins.
|
|
||||||
if is_admin():
|
|
||||||
schema = SubmissionSchema(view=view, many=True)
|
|
||||||
response = schema.dump(fails)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
data = response.data
|
|
||||||
else:
|
|
||||||
data = []
|
|
||||||
count = len(fails)
|
|
||||||
|
|
||||||
return {"success": True, "data": data, "meta": {"count": count}}
|
|
||||||
|
|
||||||
|
|
||||||
@teams_namespace.route("/<team_id>/awards")
|
|
||||||
@teams_namespace.param("team_id", "Team ID")
|
|
||||||
class TeamPublicAwards(Resource):
|
|
||||||
method_decorators = [require_team_mode]
|
|
||||||
|
|
||||||
@check_account_visibility
|
|
||||||
@check_score_visibility
|
|
||||||
def get(self, team_id):
|
|
||||||
team = Teams.query.filter_by(id=team_id).first_or_404()
|
|
||||||
|
|
||||||
if (team.banned or team.hidden) and is_admin() is False:
|
|
||||||
abort(404)
|
|
||||||
awards = team.get_awards(admin=is_admin())
|
|
||||||
|
|
||||||
schema = AwardSchema(many=True)
|
|
||||||
response = schema.dump(awards)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
count = len(response.data)
|
|
||||||
return {"success": True, "data": response.data, "meta": {"count": count}}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import datetime
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from flask import request, session
|
|
||||||
from flask_restx import Namespace, Resource
|
|
||||||
|
|
||||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
|
||||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
|
||||||
from CTFd.models import Tokens, db
|
|
||||||
from CTFd.schemas.tokens import TokenSchema
|
|
||||||
from CTFd.utils.decorators import authed_only, require_verified_emails
|
|
||||||
from CTFd.utils.security.auth import generate_user_token
|
|
||||||
from CTFd.utils.user import get_current_user, get_current_user_type, is_admin
|
|
||||||
|
|
||||||
tokens_namespace = Namespace("tokens", description="Endpoint to retrieve Tokens")
|
|
||||||
|
|
||||||
TokenModel = sqlalchemy_to_pydantic(Tokens)
|
|
||||||
ValuelessTokenModel = sqlalchemy_to_pydantic(Tokens, exclude=["value"])
|
|
||||||
|
|
||||||
|
|
||||||
class TokenDetailedSuccessResponse(APIDetailedSuccessResponse):
|
|
||||||
data: TokenModel
|
|
||||||
|
|
||||||
|
|
||||||
class ValuelessTokenDetailedSuccessResponse(APIDetailedSuccessResponse):
|
|
||||||
data: ValuelessTokenModel
|
|
||||||
|
|
||||||
|
|
||||||
class TokenListSuccessResponse(APIListSuccessResponse):
|
|
||||||
data: List[TokenModel]
|
|
||||||
|
|
||||||
|
|
||||||
tokens_namespace.schema_model(
|
|
||||||
"TokenDetailedSuccessResponse", TokenDetailedSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
tokens_namespace.schema_model(
|
|
||||||
"ValuelessTokenDetailedSuccessResponse",
|
|
||||||
ValuelessTokenDetailedSuccessResponse.apidoc(),
|
|
||||||
)
|
|
||||||
|
|
||||||
tokens_namespace.schema_model(
|
|
||||||
"TokenListSuccessResponse", TokenListSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@tokens_namespace.route("")
|
|
||||||
class TokenList(Resource):
|
|
||||||
@require_verified_emails
|
|
||||||
@authed_only
|
|
||||||
@tokens_namespace.doc(
|
|
||||||
description="Endpoint to get token objects in bulk",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "TokenListSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def get(self):
|
|
||||||
user = get_current_user()
|
|
||||||
tokens = Tokens.query.filter_by(user_id=user.id)
|
|
||||||
response = TokenSchema(view=["id", "type", "expiration"], many=True).dump(
|
|
||||||
tokens
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@require_verified_emails
|
|
||||||
@authed_only
|
|
||||||
@tokens_namespace.doc(
|
|
||||||
description="Endpoint to create a token object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "TokenDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def post(self):
|
|
||||||
req = request.get_json()
|
|
||||||
expiration = req.get("expiration")
|
|
||||||
if expiration:
|
|
||||||
expiration = datetime.datetime.strptime(expiration, "%Y-%m-%d")
|
|
||||||
|
|
||||||
user = get_current_user()
|
|
||||||
token = generate_user_token(user, expiration=expiration)
|
|
||||||
|
|
||||||
# Explicitly use admin view so that user's can see the value of their token
|
|
||||||
schema = TokenSchema(view="admin")
|
|
||||||
response = schema.dump(token)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
|
|
||||||
@tokens_namespace.route("/<token_id>")
|
|
||||||
@tokens_namespace.param("token_id", "A Token ID")
|
|
||||||
class TokenDetail(Resource):
|
|
||||||
@require_verified_emails
|
|
||||||
@authed_only
|
|
||||||
@tokens_namespace.doc(
|
|
||||||
description="Endpoint to get an existing token object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "ValuelessTokenDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def get(self, token_id):
|
|
||||||
if is_admin():
|
|
||||||
token = Tokens.query.filter_by(id=token_id).first_or_404()
|
|
||||||
else:
|
|
||||||
token = Tokens.query.filter_by(
|
|
||||||
id=token_id, user_id=session["id"]
|
|
||||||
).first_or_404()
|
|
||||||
|
|
||||||
user_type = get_current_user_type(fallback="user")
|
|
||||||
schema = TokenSchema(view=user_type)
|
|
||||||
response = schema.dump(token)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@require_verified_emails
|
|
||||||
@authed_only
|
|
||||||
@tokens_namespace.doc(
|
|
||||||
description="Endpoint to delete an existing token object",
|
|
||||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
|
||||||
)
|
|
||||||
def delete(self, token_id):
|
|
||||||
if is_admin():
|
|
||||||
token = Tokens.query.filter_by(id=token_id).first_or_404()
|
|
||||||
else:
|
|
||||||
user = get_current_user()
|
|
||||||
token = Tokens.query.filter_by(id=token_id, user_id=user.id).first_or_404()
|
|
||||||
db.session.delete(token)
|
|
||||||
db.session.commit()
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
from typing import List
|
|
||||||
|
|
||||||
from flask import request
|
|
||||||
from flask_restx import Namespace, Resource
|
|
||||||
|
|
||||||
from CTFd.api.v1.helpers.request import validate_args
|
|
||||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
|
||||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
|
||||||
from CTFd.constants import RawEnum
|
|
||||||
from CTFd.models import ChallengeTopics, Topics, db
|
|
||||||
from CTFd.schemas.topics import ChallengeTopicSchema, TopicSchema
|
|
||||||
from CTFd.utils.decorators import admins_only
|
|
||||||
from CTFd.utils.helpers.models import build_model_filters
|
|
||||||
|
|
||||||
topics_namespace = Namespace("topics", description="Endpoint to retrieve Topics")
|
|
||||||
|
|
||||||
TopicModel = sqlalchemy_to_pydantic(Topics)
|
|
||||||
|
|
||||||
|
|
||||||
class TopicDetailedSuccessResponse(APIDetailedSuccessResponse):
|
|
||||||
data: TopicModel
|
|
||||||
|
|
||||||
|
|
||||||
class TopicListSuccessResponse(APIListSuccessResponse):
|
|
||||||
data: List[TopicModel]
|
|
||||||
|
|
||||||
|
|
||||||
topics_namespace.schema_model(
|
|
||||||
"TopicDetailedSuccessResponse", TopicDetailedSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
topics_namespace.schema_model(
|
|
||||||
"TopicListSuccessResponse", TopicListSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@topics_namespace.route("")
|
|
||||||
class TopicList(Resource):
|
|
||||||
@admins_only
|
|
||||||
@topics_namespace.doc(
|
|
||||||
description="Endpoint to list Topic objects in bulk",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "TopicListSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@validate_args(
|
|
||||||
{
|
|
||||||
"value": (str, None),
|
|
||||||
"q": (str, None),
|
|
||||||
"field": (RawEnum("TopicFields", {"value": "value"}), None,),
|
|
||||||
},
|
|
||||||
location="query",
|
|
||||||
)
|
|
||||||
def get(self, query_args):
|
|
||||||
q = query_args.pop("q", None)
|
|
||||||
field = str(query_args.pop("field", None))
|
|
||||||
filters = build_model_filters(model=Topics, query=q, field=field)
|
|
||||||
|
|
||||||
topics = Topics.query.filter_by(**query_args).filter(*filters).all()
|
|
||||||
schema = TopicSchema(many=True)
|
|
||||||
response = schema.dump(topics)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@topics_namespace.doc(
|
|
||||||
description="Endpoint to create a Topic object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "TopicDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def post(self):
|
|
||||||
req = request.get_json()
|
|
||||||
value = req.get("value")
|
|
||||||
|
|
||||||
if value:
|
|
||||||
topic = Topics.query.filter_by(value=value).first()
|
|
||||||
if topic is None:
|
|
||||||
schema = TopicSchema()
|
|
||||||
response = schema.load(req, session=db.session)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
topic = response.data
|
|
||||||
db.session.add(topic)
|
|
||||||
db.session.commit()
|
|
||||||
else:
|
|
||||||
topic_id = req.get("topic_id")
|
|
||||||
topic = Topics.query.filter_by(id=topic_id).first_or_404()
|
|
||||||
|
|
||||||
req["topic_id"] = topic.id
|
|
||||||
topic_type = req.get("type")
|
|
||||||
if topic_type == "challenge":
|
|
||||||
schema = ChallengeTopicSchema()
|
|
||||||
response = schema.load(req, session=db.session)
|
|
||||||
else:
|
|
||||||
return {"success": False}, 400
|
|
||||||
|
|
||||||
db.session.add(response.data)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
response = schema.dump(response.data)
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@topics_namespace.doc(
|
|
||||||
description="Endpoint to delete a specific Topic object of a specific type",
|
|
||||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
|
||||||
)
|
|
||||||
@validate_args(
|
|
||||||
{"type": (str, None), "target_id": (int, 0)}, location="query",
|
|
||||||
)
|
|
||||||
def delete(self, query_args):
|
|
||||||
topic_type = query_args.get("type")
|
|
||||||
target_id = int(query_args.get("target_id", 0))
|
|
||||||
|
|
||||||
if topic_type == "challenge":
|
|
||||||
Model = ChallengeTopics
|
|
||||||
else:
|
|
||||||
return {"success": False}, 400
|
|
||||||
|
|
||||||
topic = Model.query.filter_by(id=target_id).first_or_404()
|
|
||||||
db.session.delete(topic)
|
|
||||||
db.session.commit()
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
|
|
||||||
@topics_namespace.route("/<topic_id>")
|
|
||||||
class Topic(Resource):
|
|
||||||
@admins_only
|
|
||||||
@topics_namespace.doc(
|
|
||||||
description="Endpoint to get a specific Topic object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "TopicDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def get(self, topic_id):
|
|
||||||
topic = Topics.query.filter_by(id=topic_id).first_or_404()
|
|
||||||
response = TopicSchema().dump(topic)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@topics_namespace.doc(
|
|
||||||
description="Endpoint to delete a specific Topic object",
|
|
||||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
|
||||||
)
|
|
||||||
def delete(self, topic_id):
|
|
||||||
topic = Topics.query.filter_by(id=topic_id).first_or_404()
|
|
||||||
db.session.delete(topic)
|
|
||||||
db.session.commit()
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
from typing import List
|
|
||||||
|
|
||||||
from flask import request
|
|
||||||
from flask_restx import Namespace, Resource
|
|
||||||
|
|
||||||
from CTFd.api.v1.helpers.request import validate_args
|
|
||||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
|
||||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
|
||||||
from CTFd.cache import clear_standings
|
|
||||||
from CTFd.constants import RawEnum
|
|
||||||
from CTFd.models import Unlocks, db, get_class_by_tablename
|
|
||||||
from CTFd.schemas.awards import AwardSchema
|
|
||||||
from CTFd.schemas.unlocks import UnlockSchema
|
|
||||||
from CTFd.utils.decorators import (
|
|
||||||
admins_only,
|
|
||||||
authed_only,
|
|
||||||
during_ctf_time_only,
|
|
||||||
require_verified_emails,
|
|
||||||
)
|
|
||||||
from CTFd.utils.helpers.models import build_model_filters
|
|
||||||
from CTFd.utils.user import get_current_user
|
|
||||||
|
|
||||||
unlocks_namespace = Namespace("unlocks", description="Endpoint to retrieve Unlocks")
|
|
||||||
|
|
||||||
UnlockModel = sqlalchemy_to_pydantic(Unlocks)
|
|
||||||
TransientUnlockModel = sqlalchemy_to_pydantic(Unlocks, exclude=["id"])
|
|
||||||
|
|
||||||
|
|
||||||
class UnlockDetailedSuccessResponse(APIDetailedSuccessResponse):
|
|
||||||
data: UnlockModel
|
|
||||||
|
|
||||||
|
|
||||||
class UnlockListSuccessResponse(APIListSuccessResponse):
|
|
||||||
data: List[UnlockModel]
|
|
||||||
|
|
||||||
|
|
||||||
unlocks_namespace.schema_model(
|
|
||||||
"UnlockDetailedSuccessResponse", UnlockDetailedSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
unlocks_namespace.schema_model(
|
|
||||||
"UnlockListSuccessResponse", UnlockListSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@unlocks_namespace.route("")
|
|
||||||
class UnlockList(Resource):
|
|
||||||
@admins_only
|
|
||||||
@unlocks_namespace.doc(
|
|
||||||
description="Endpoint to get unlock objects in bulk",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "UnlockListSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@validate_args(
|
|
||||||
{
|
|
||||||
"user_id": (int, None),
|
|
||||||
"team_id": (int, None),
|
|
||||||
"target": (int, None),
|
|
||||||
"type": (str, None),
|
|
||||||
"q": (str, None),
|
|
||||||
"field": (
|
|
||||||
RawEnum("UnlockFields", {"target": "target", "type": "type"}),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
location="query",
|
|
||||||
)
|
|
||||||
def get(self, query_args):
|
|
||||||
q = query_args.pop("q", None)
|
|
||||||
field = str(query_args.pop("field", None))
|
|
||||||
filters = build_model_filters(model=Unlocks, query=q, field=field)
|
|
||||||
|
|
||||||
unlocks = Unlocks.query.filter_by(**query_args).filter(*filters).all()
|
|
||||||
schema = UnlockSchema()
|
|
||||||
response = schema.dump(unlocks)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@during_ctf_time_only
|
|
||||||
@require_verified_emails
|
|
||||||
@authed_only
|
|
||||||
@unlocks_namespace.doc(
|
|
||||||
description="Endpoint to create an unlock object. Used to unlock hints.",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "UnlockDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def post(self):
|
|
||||||
req = request.get_json()
|
|
||||||
user = get_current_user()
|
|
||||||
|
|
||||||
req["user_id"] = user.id
|
|
||||||
req["team_id"] = user.team_id
|
|
||||||
|
|
||||||
Model = get_class_by_tablename(req["type"])
|
|
||||||
target = Model.query.filter_by(id=req["target"]).first_or_404()
|
|
||||||
|
|
||||||
# We should use the team's score if in teams mode
|
|
||||||
# user.account gives the appropriate account based on team mode
|
|
||||||
if target.cost > user.account.score:
|
|
||||||
return (
|
|
||||||
{
|
|
||||||
"success": False,
|
|
||||||
"errors": {
|
|
||||||
"score": "You do not have enough points to unlock this hint"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
|
|
||||||
schema = UnlockSchema()
|
|
||||||
response = schema.load(req, session=db.session)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
# Search for an existing unlock that matches the target and type
|
|
||||||
# And matches either the requesting user id or the requesting team id
|
|
||||||
existing = Unlocks.query.filter(
|
|
||||||
Unlocks.target == req["target"],
|
|
||||||
Unlocks.type == req["type"],
|
|
||||||
Unlocks.account_id == user.account_id,
|
|
||||||
).first()
|
|
||||||
if existing:
|
|
||||||
return (
|
|
||||||
{
|
|
||||||
"success": False,
|
|
||||||
"errors": {"target": "You've already unlocked this this target"},
|
|
||||||
},
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
|
|
||||||
db.session.add(response.data)
|
|
||||||
|
|
||||||
award_schema = AwardSchema()
|
|
||||||
award = {
|
|
||||||
"user_id": user.id,
|
|
||||||
"team_id": user.team_id,
|
|
||||||
"name": target.name,
|
|
||||||
"description": target.description,
|
|
||||||
"value": (-target.cost),
|
|
||||||
"category": target.category,
|
|
||||||
}
|
|
||||||
|
|
||||||
award = award_schema.load(award)
|
|
||||||
db.session.add(award.data)
|
|
||||||
db.session.commit()
|
|
||||||
clear_standings()
|
|
||||||
|
|
||||||
response = schema.dump(response.data)
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
@@ -1,502 +0,0 @@
|
|||||||
from typing import List
|
|
||||||
|
|
||||||
from flask import abort, request, session
|
|
||||||
from flask_restx import Namespace, Resource
|
|
||||||
|
|
||||||
from CTFd.api.v1.helpers.request import validate_args
|
|
||||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
|
||||||
from CTFd.api.v1.schemas import (
|
|
||||||
APIDetailedSuccessResponse,
|
|
||||||
PaginatedAPIListSuccessResponse,
|
|
||||||
)
|
|
||||||
from CTFd.cache import clear_challenges, clear_standings, clear_user_session
|
|
||||||
from CTFd.constants import RawEnum
|
|
||||||
from CTFd.models import (
|
|
||||||
Awards,
|
|
||||||
Notifications,
|
|
||||||
Solves,
|
|
||||||
Submissions,
|
|
||||||
Tracking,
|
|
||||||
Unlocks,
|
|
||||||
Users,
|
|
||||||
db,
|
|
||||||
)
|
|
||||||
from CTFd.schemas.awards import AwardSchema
|
|
||||||
from CTFd.schemas.submissions import SubmissionSchema
|
|
||||||
from CTFd.schemas.users import UserSchema
|
|
||||||
from CTFd.utils.config import get_mail_provider
|
|
||||||
from CTFd.utils.decorators import admins_only, authed_only, ratelimit
|
|
||||||
from CTFd.utils.decorators.visibility import (
|
|
||||||
check_account_visibility,
|
|
||||||
check_score_visibility,
|
|
||||||
)
|
|
||||||
from CTFd.utils.email import sendmail, user_created_notification
|
|
||||||
from CTFd.utils.helpers.models import build_model_filters
|
|
||||||
from CTFd.utils.security.auth import update_user
|
|
||||||
from CTFd.utils.user import get_current_user, get_current_user_type, is_admin
|
|
||||||
|
|
||||||
users_namespace = Namespace("users", description="Endpoint to retrieve Users")
|
|
||||||
|
|
||||||
|
|
||||||
UserModel = sqlalchemy_to_pydantic(Users)
|
|
||||||
TransientUserModel = sqlalchemy_to_pydantic(Users, exclude=["id"])
|
|
||||||
|
|
||||||
|
|
||||||
class UserDetailedSuccessResponse(APIDetailedSuccessResponse):
|
|
||||||
data: UserModel
|
|
||||||
|
|
||||||
|
|
||||||
class UserListSuccessResponse(PaginatedAPIListSuccessResponse):
|
|
||||||
data: List[UserModel]
|
|
||||||
|
|
||||||
|
|
||||||
users_namespace.schema_model(
|
|
||||||
"UserDetailedSuccessResponse", UserDetailedSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
users_namespace.schema_model(
|
|
||||||
"UserListSuccessResponse", UserListSuccessResponse.apidoc()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@users_namespace.route("")
|
|
||||||
class UserList(Resource):
|
|
||||||
@check_account_visibility
|
|
||||||
@users_namespace.doc(
|
|
||||||
description="Endpoint to get User objects in bulk",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "UserListSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@validate_args(
|
|
||||||
{
|
|
||||||
"affiliation": (str, None),
|
|
||||||
"country": (str, None),
|
|
||||||
"bracket": (str, None),
|
|
||||||
"q": (str, None),
|
|
||||||
"field": (
|
|
||||||
RawEnum(
|
|
||||||
"UserFields",
|
|
||||||
{
|
|
||||||
"name": "name",
|
|
||||||
"website": "website",
|
|
||||||
"country": "country",
|
|
||||||
"bracket": "bracket",
|
|
||||||
"affiliation": "affiliation",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
location="query",
|
|
||||||
)
|
|
||||||
def get(self, query_args):
|
|
||||||
q = query_args.pop("q", None)
|
|
||||||
field = str(query_args.pop("field", None))
|
|
||||||
filters = build_model_filters(model=Users, query=q, field=field)
|
|
||||||
|
|
||||||
if is_admin() and request.args.get("view") == "admin":
|
|
||||||
users = (
|
|
||||||
Users.query.filter_by(**query_args)
|
|
||||||
.filter(*filters)
|
|
||||||
.paginate(per_page=50, max_per_page=100)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
users = (
|
|
||||||
Users.query.filter_by(banned=False, hidden=False, **query_args)
|
|
||||||
.filter(*filters)
|
|
||||||
.paginate(per_page=50, max_per_page=100)
|
|
||||||
)
|
|
||||||
|
|
||||||
response = UserSchema(view="user", many=True).dump(users.items)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
return {
|
|
||||||
"meta": {
|
|
||||||
"pagination": {
|
|
||||||
"page": users.page,
|
|
||||||
"next": users.next_num,
|
|
||||||
"prev": users.prev_num,
|
|
||||||
"pages": users.pages,
|
|
||||||
"per_page": users.per_page,
|
|
||||||
"total": users.total,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"success": True,
|
|
||||||
"data": response.data,
|
|
||||||
}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@users_namespace.doc(
|
|
||||||
description="Endpoint to create a User object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "UserDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
params={
|
|
||||||
"notify": "Whether to send the created user an email with their credentials"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def post(self):
|
|
||||||
req = request.get_json()
|
|
||||||
schema = UserSchema("admin")
|
|
||||||
response = schema.load(req)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
db.session.add(response.data)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
if request.args.get("notify"):
|
|
||||||
name = response.data.name
|
|
||||||
email = response.data.email
|
|
||||||
password = req.get("password")
|
|
||||||
|
|
||||||
user_created_notification(addr=email, name=name, password=password)
|
|
||||||
|
|
||||||
clear_standings()
|
|
||||||
clear_challenges()
|
|
||||||
|
|
||||||
response = schema.dump(response.data)
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
|
|
||||||
@users_namespace.route("/<int:user_id>")
|
|
||||||
@users_namespace.param("user_id", "User ID")
|
|
||||||
class UserPublic(Resource):
|
|
||||||
@check_account_visibility
|
|
||||||
@users_namespace.doc(
|
|
||||||
description="Endpoint to get a specific User object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "UserDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def get(self, user_id):
|
|
||||||
user = Users.query.filter_by(id=user_id).first_or_404()
|
|
||||||
|
|
||||||
if (user.banned or user.hidden) and is_admin() is False:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
user_type = get_current_user_type(fallback="user")
|
|
||||||
response = UserSchema(view=user_type).dump(user)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
response.data["place"] = user.place
|
|
||||||
response.data["score"] = user.score
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@users_namespace.doc(
|
|
||||||
description="Endpoint to edit a specific User object",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "UserDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def patch(self, user_id):
|
|
||||||
user = Users.query.filter_by(id=user_id).first_or_404()
|
|
||||||
data = request.get_json()
|
|
||||||
data["id"] = user_id
|
|
||||||
|
|
||||||
# Admins should not be able to ban themselves
|
|
||||||
if data["id"] == session["id"] and (
|
|
||||||
data.get("banned") is True or data.get("banned") == "true"
|
|
||||||
):
|
|
||||||
return (
|
|
||||||
{"success": False, "errors": {"id": "You cannot ban yourself"}},
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
|
|
||||||
schema = UserSchema(view="admin", instance=user, partial=True)
|
|
||||||
response = schema.load(data)
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
# This generates the response first before actually changing the type
|
|
||||||
# This avoids an error during User type changes where we change
|
|
||||||
# the polymorphic identity resulting in an ObjectDeletedError
|
|
||||||
# https://github.com/CTFd/CTFd/issues/1794
|
|
||||||
response = schema.dump(response.data)
|
|
||||||
db.session.commit()
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
clear_user_session(user_id=user_id)
|
|
||||||
clear_standings()
|
|
||||||
clear_challenges()
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
@admins_only
|
|
||||||
@users_namespace.doc(
|
|
||||||
description="Endpoint to delete a specific User object",
|
|
||||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
|
||||||
)
|
|
||||||
def delete(self, user_id):
|
|
||||||
# Admins should not be able to delete themselves
|
|
||||||
if user_id == session["id"]:
|
|
||||||
return (
|
|
||||||
{"success": False, "errors": {"id": "You cannot delete yourself"}},
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
|
|
||||||
Notifications.query.filter_by(user_id=user_id).delete()
|
|
||||||
Awards.query.filter_by(user_id=user_id).delete()
|
|
||||||
Unlocks.query.filter_by(user_id=user_id).delete()
|
|
||||||
Submissions.query.filter_by(user_id=user_id).delete()
|
|
||||||
Solves.query.filter_by(user_id=user_id).delete()
|
|
||||||
Tracking.query.filter_by(user_id=user_id).delete()
|
|
||||||
Users.query.filter_by(id=user_id).delete()
|
|
||||||
db.session.commit()
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
clear_user_session(user_id=user_id)
|
|
||||||
clear_standings()
|
|
||||||
clear_challenges()
|
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
|
|
||||||
@users_namespace.route("/me")
|
|
||||||
class UserPrivate(Resource):
|
|
||||||
@authed_only
|
|
||||||
@users_namespace.doc(
|
|
||||||
description="Endpoint to get the User object for the current user",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "UserDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def get(self):
|
|
||||||
user = get_current_user()
|
|
||||||
response = UserSchema("self").dump(user).data
|
|
||||||
response["place"] = user.place
|
|
||||||
response["score"] = user.score
|
|
||||||
return {"success": True, "data": response}
|
|
||||||
|
|
||||||
@authed_only
|
|
||||||
@users_namespace.doc(
|
|
||||||
description="Endpoint to edit the User object for the current user",
|
|
||||||
responses={
|
|
||||||
200: ("Success", "UserDetailedSuccessResponse"),
|
|
||||||
400: (
|
|
||||||
"An error occured processing the provided or stored data",
|
|
||||||
"APISimpleErrorResponse",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def patch(self):
|
|
||||||
user = get_current_user()
|
|
||||||
data = request.get_json()
|
|
||||||
schema = UserSchema(view="self", instance=user, partial=True)
|
|
||||||
response = schema.load(data)
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Update user's session for the new session hash
|
|
||||||
update_user(user)
|
|
||||||
|
|
||||||
response = schema.dump(response.data)
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
clear_standings()
|
|
||||||
clear_challenges()
|
|
||||||
|
|
||||||
return {"success": True, "data": response.data}
|
|
||||||
|
|
||||||
|
|
||||||
@users_namespace.route("/me/solves")
|
|
||||||
class UserPrivateSolves(Resource):
|
|
||||||
@authed_only
|
|
||||||
def get(self):
|
|
||||||
user = get_current_user()
|
|
||||||
solves = user.get_solves(admin=True)
|
|
||||||
|
|
||||||
view = "user" if not is_admin() else "admin"
|
|
||||||
response = SubmissionSchema(view=view, many=True).dump(solves)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
count = len(response.data)
|
|
||||||
return {"success": True, "data": response.data, "meta": {"count": count}}
|
|
||||||
|
|
||||||
|
|
||||||
@users_namespace.route("/me/fails")
|
|
||||||
class UserPrivateFails(Resource):
|
|
||||||
@authed_only
|
|
||||||
def get(self):
|
|
||||||
user = get_current_user()
|
|
||||||
fails = user.get_fails(admin=True)
|
|
||||||
|
|
||||||
view = "user" if not is_admin() else "admin"
|
|
||||||
|
|
||||||
# We want to return the count purely for stats & graphs
|
|
||||||
# but this data isn't really needed by the end user.
|
|
||||||
# Only actually show fail data for admins.
|
|
||||||
if is_admin():
|
|
||||||
response = SubmissionSchema(view=view, many=True).dump(fails)
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
data = response.data
|
|
||||||
else:
|
|
||||||
data = []
|
|
||||||
|
|
||||||
count = len(fails)
|
|
||||||
return {"success": True, "data": data, "meta": {"count": count}}
|
|
||||||
|
|
||||||
|
|
||||||
@users_namespace.route("/me/awards")
|
|
||||||
@users_namespace.param("user_id", "User ID")
|
|
||||||
class UserPrivateAwards(Resource):
|
|
||||||
@authed_only
|
|
||||||
def get(self):
|
|
||||||
user = get_current_user()
|
|
||||||
awards = user.get_awards(admin=True)
|
|
||||||
|
|
||||||
view = "user" if not is_admin() else "admin"
|
|
||||||
response = AwardSchema(view=view, many=True).dump(awards)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
count = len(response.data)
|
|
||||||
return {"success": True, "data": response.data, "meta": {"count": count}}
|
|
||||||
|
|
||||||
|
|
||||||
@users_namespace.route("/<user_id>/solves")
|
|
||||||
@users_namespace.param("user_id", "User ID")
|
|
||||||
class UserPublicSolves(Resource):
|
|
||||||
@check_account_visibility
|
|
||||||
@check_score_visibility
|
|
||||||
def get(self, user_id):
|
|
||||||
user = Users.query.filter_by(id=user_id).first_or_404()
|
|
||||||
|
|
||||||
if (user.banned or user.hidden) and is_admin() is False:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
solves = user.get_solves(admin=is_admin())
|
|
||||||
|
|
||||||
view = "user" if not is_admin() else "admin"
|
|
||||||
response = SubmissionSchema(view=view, many=True).dump(solves)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
count = len(response.data)
|
|
||||||
return {"success": True, "data": response.data, "meta": {"count": count}}
|
|
||||||
|
|
||||||
|
|
||||||
@users_namespace.route("/<user_id>/fails")
|
|
||||||
@users_namespace.param("user_id", "User ID")
|
|
||||||
class UserPublicFails(Resource):
|
|
||||||
@check_account_visibility
|
|
||||||
@check_score_visibility
|
|
||||||
def get(self, user_id):
|
|
||||||
user = Users.query.filter_by(id=user_id).first_or_404()
|
|
||||||
|
|
||||||
if (user.banned or user.hidden) and is_admin() is False:
|
|
||||||
abort(404)
|
|
||||||
fails = user.get_fails(admin=is_admin())
|
|
||||||
|
|
||||||
view = "user" if not is_admin() else "admin"
|
|
||||||
|
|
||||||
# We want to return the count purely for stats & graphs
|
|
||||||
# but this data isn't really needed by the end user.
|
|
||||||
# Only actually show fail data for admins.
|
|
||||||
if is_admin():
|
|
||||||
response = SubmissionSchema(view=view, many=True).dump(fails)
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
data = response.data
|
|
||||||
else:
|
|
||||||
data = []
|
|
||||||
|
|
||||||
count = len(fails)
|
|
||||||
return {"success": True, "data": data, "meta": {"count": count}}
|
|
||||||
|
|
||||||
|
|
||||||
@users_namespace.route("/<user_id>/awards")
|
|
||||||
@users_namespace.param("user_id", "User ID or 'me'")
|
|
||||||
class UserPublicAwards(Resource):
|
|
||||||
@check_account_visibility
|
|
||||||
@check_score_visibility
|
|
||||||
def get(self, user_id):
|
|
||||||
user = Users.query.filter_by(id=user_id).first_or_404()
|
|
||||||
|
|
||||||
if (user.banned or user.hidden) and is_admin() is False:
|
|
||||||
abort(404)
|
|
||||||
awards = user.get_awards(admin=is_admin())
|
|
||||||
|
|
||||||
view = "user" if not is_admin() else "admin"
|
|
||||||
response = AwardSchema(view=view, many=True).dump(awards)
|
|
||||||
|
|
||||||
if response.errors:
|
|
||||||
return {"success": False, "errors": response.errors}, 400
|
|
||||||
|
|
||||||
count = len(response.data)
|
|
||||||
return {"success": True, "data": response.data, "meta": {"count": count}}
|
|
||||||
|
|
||||||
|
|
||||||
@users_namespace.route("/<int:user_id>/email")
|
|
||||||
@users_namespace.param("user_id", "User ID")
|
|
||||||
class UserEmails(Resource):
|
|
||||||
@admins_only
|
|
||||||
@users_namespace.doc(
|
|
||||||
description="Endpoint to email a User object",
|
|
||||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
|
||||||
)
|
|
||||||
@ratelimit(method="POST", limit=10, interval=60)
|
|
||||||
def post(self, user_id):
|
|
||||||
req = request.get_json()
|
|
||||||
text = req.get("text", "").strip()
|
|
||||||
user = Users.query.filter_by(id=user_id).first_or_404()
|
|
||||||
|
|
||||||
if get_mail_provider() is None:
|
|
||||||
return (
|
|
||||||
{"success": False, "errors": {"": ["Email settings not configured"]}},
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not text:
|
|
||||||
return (
|
|
||||||
{"success": False, "errors": {"text": ["Email text cannot be empty"]}},
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
|
|
||||||
result, response = sendmail(addr=user.email, text=text)
|
|
||||||
|
|
||||||
if result is True:
|
|
||||||
return {"success": True}
|
|
||||||
else:
|
|
||||||
return (
|
|
||||||
{"success": False, "errors": {"": [response]}},
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
569
CTFd/auth.py
569
CTFd/auth.py
@@ -1,569 +0,0 @@
|
|||||||
import base64 # noqa: I001
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from flask import Blueprint, abort
|
|
||||||
from flask import current_app as app
|
|
||||||
from flask import redirect, render_template, request, session, url_for
|
|
||||||
from itsdangerous.exc import BadSignature, BadTimeSignature, SignatureExpired
|
|
||||||
|
|
||||||
from CTFd.cache import clear_team_session, clear_user_session
|
|
||||||
from CTFd.models import Teams, UserFieldEntries, UserFields, Users, db
|
|
||||||
from CTFd.utils import config, email, get_app_config, get_config
|
|
||||||
from CTFd.utils import user as current_user
|
|
||||||
from CTFd.utils import validators
|
|
||||||
from CTFd.utils.config import is_teams_mode
|
|
||||||
from CTFd.utils.config.integrations import mlc_registration
|
|
||||||
from CTFd.utils.config.visibility import registration_visible
|
|
||||||
from CTFd.utils.crypto import verify_password
|
|
||||||
from CTFd.utils.decorators import ratelimit
|
|
||||||
from CTFd.utils.decorators.visibility import check_registration_visibility
|
|
||||||
from CTFd.utils.helpers import error_for, get_errors, markup
|
|
||||||
from CTFd.utils.logging import log
|
|
||||||
from CTFd.utils.modes import TEAMS_MODE
|
|
||||||
from CTFd.utils.security.auth import login_user, logout_user
|
|
||||||
from CTFd.utils.security.signing import unserialize
|
|
||||||
from CTFd.utils.validators import ValidationError
|
|
||||||
|
|
||||||
auth = Blueprint("auth", __name__)
|
|
||||||
|
|
||||||
|
|
||||||
@auth.route("/confirm", methods=["POST", "GET"])
|
|
||||||
@auth.route("/confirm/<data>", methods=["POST", "GET"])
|
|
||||||
@ratelimit(method="POST", limit=10, interval=60)
|
|
||||||
def confirm(data=None):
|
|
||||||
if not get_config("verify_emails"):
|
|
||||||
# If the CTF doesn't care about confirming email addresses then redierct to challenges
|
|
||||||
return redirect(url_for("challenges.listing"))
|
|
||||||
|
|
||||||
# User is confirming email account
|
|
||||||
if data and request.method == "GET":
|
|
||||||
try:
|
|
||||||
user_email = unserialize(data, max_age=1800)
|
|
||||||
except (BadTimeSignature, SignatureExpired):
|
|
||||||
return render_template(
|
|
||||||
"confirm.html", errors=["Your confirmation link has expired"]
|
|
||||||
)
|
|
||||||
except (BadSignature, TypeError, base64.binascii.Error):
|
|
||||||
return render_template(
|
|
||||||
"confirm.html", errors=["Your confirmation token is invalid"]
|
|
||||||
)
|
|
||||||
|
|
||||||
user = Users.query.filter_by(email=user_email).first_or_404()
|
|
||||||
if user.verified:
|
|
||||||
return redirect(url_for("views.settings"))
|
|
||||||
|
|
||||||
user.verified = True
|
|
||||||
log(
|
|
||||||
"registrations",
|
|
||||||
format="[{date}] {ip} - successful confirmation for {name}",
|
|
||||||
name=user.name,
|
|
||||||
)
|
|
||||||
db.session.commit()
|
|
||||||
clear_user_session(user_id=user.id)
|
|
||||||
email.successful_registration_notification(user.email)
|
|
||||||
db.session.close()
|
|
||||||
if current_user.authed():
|
|
||||||
return redirect(url_for("challenges.listing"))
|
|
||||||
return redirect(url_for("auth.login"))
|
|
||||||
|
|
||||||
# User is trying to start or restart the confirmation flow
|
|
||||||
if current_user.authed() is False:
|
|
||||||
return redirect(url_for("auth.login"))
|
|
||||||
|
|
||||||
user = Users.query.filter_by(id=session["id"]).first_or_404()
|
|
||||||
if user.verified:
|
|
||||||
return redirect(url_for("views.settings"))
|
|
||||||
|
|
||||||
if data is None:
|
|
||||||
if request.method == "POST":
|
|
||||||
# User wants to resend their confirmation email
|
|
||||||
email.verify_email_address(user.email)
|
|
||||||
log(
|
|
||||||
"registrations",
|
|
||||||
format="[{date}] {ip} - {name} initiated a confirmation email resend",
|
|
||||||
name=user.name,
|
|
||||||
)
|
|
||||||
return render_template(
|
|
||||||
"confirm.html", infos=[f"Confirmation email sent to {user.email}!"]
|
|
||||||
)
|
|
||||||
elif request.method == "GET":
|
|
||||||
# User has been directed to the confirm page
|
|
||||||
return render_template("confirm.html")
|
|
||||||
|
|
||||||
|
|
||||||
@auth.route("/reset_password", methods=["POST", "GET"])
|
|
||||||
@auth.route("/reset_password/<data>", methods=["POST", "GET"])
|
|
||||||
@ratelimit(method="POST", limit=10, interval=60)
|
|
||||||
def reset_password(data=None):
|
|
||||||
if config.can_send_mail() is False:
|
|
||||||
return render_template(
|
|
||||||
"reset_password.html",
|
|
||||||
errors=[
|
|
||||||
markup(
|
|
||||||
"This CTF is not configured to send email.<br> Please contact an organizer to have your password reset."
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
if data is not None:
|
|
||||||
try:
|
|
||||||
email_address = unserialize(data, max_age=1800)
|
|
||||||
except (BadTimeSignature, SignatureExpired):
|
|
||||||
return render_template(
|
|
||||||
"reset_password.html", errors=["Your link has expired"]
|
|
||||||
)
|
|
||||||
except (BadSignature, TypeError, base64.binascii.Error):
|
|
||||||
return render_template(
|
|
||||||
"reset_password.html", errors=["Your reset token is invalid"]
|
|
||||||
)
|
|
||||||
|
|
||||||
if request.method == "GET":
|
|
||||||
return render_template("reset_password.html", mode="set")
|
|
||||||
if request.method == "POST":
|
|
||||||
password = request.form.get("password", "").strip()
|
|
||||||
user = Users.query.filter_by(email=email_address).first_or_404()
|
|
||||||
if user.oauth_id:
|
|
||||||
return render_template(
|
|
||||||
"reset_password.html",
|
|
||||||
infos=[
|
|
||||||
"Your account was registered via an authentication provider and does not have an associated password. Please login via your authentication provider."
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
pass_short = len(password) == 0
|
|
||||||
if pass_short:
|
|
||||||
return render_template(
|
|
||||||
"reset_password.html", errors=["Please pick a longer password"]
|
|
||||||
)
|
|
||||||
|
|
||||||
user.password = password
|
|
||||||
db.session.commit()
|
|
||||||
clear_user_session(user_id=user.id)
|
|
||||||
log(
|
|
||||||
"logins",
|
|
||||||
format="[{date}] {ip} - successful password reset for {name}",
|
|
||||||
name=user.name,
|
|
||||||
)
|
|
||||||
db.session.close()
|
|
||||||
email.password_change_alert(user.email)
|
|
||||||
return redirect(url_for("auth.login"))
|
|
||||||
|
|
||||||
if request.method == "POST":
|
|
||||||
email_address = request.form["email"].strip()
|
|
||||||
user = Users.query.filter_by(email=email_address).first()
|
|
||||||
|
|
||||||
get_errors()
|
|
||||||
|
|
||||||
if not user:
|
|
||||||
return render_template(
|
|
||||||
"reset_password.html",
|
|
||||||
infos=[
|
|
||||||
"If that account exists you will receive an email, please check your inbox"
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
if user.oauth_id:
|
|
||||||
return render_template(
|
|
||||||
"reset_password.html",
|
|
||||||
infos=[
|
|
||||||
"The email address associated with this account was registered via an authentication provider and does not have an associated password. Please login via your authentication provider."
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
email.forgot_password(email_address)
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
"reset_password.html",
|
|
||||||
infos=[
|
|
||||||
"If that account exists you will receive an email, please check your inbox"
|
|
||||||
],
|
|
||||||
)
|
|
||||||
return render_template("reset_password.html")
|
|
||||||
|
|
||||||
|
|
||||||
@auth.route("/register", methods=["POST", "GET"])
|
|
||||||
@check_registration_visibility
|
|
||||||
@ratelimit(method="POST", limit=10, interval=5)
|
|
||||||
def register():
|
|
||||||
errors = get_errors()
|
|
||||||
if current_user.authed():
|
|
||||||
return redirect(url_for("challenges.listing"))
|
|
||||||
|
|
||||||
if request.method == "POST":
|
|
||||||
name = request.form.get("name", "").strip()
|
|
||||||
email_address = request.form.get("email", "").strip().lower()
|
|
||||||
password = request.form.get("password", "").strip()
|
|
||||||
|
|
||||||
website = request.form.get("website")
|
|
||||||
affiliation = request.form.get("affiliation")
|
|
||||||
country = request.form.get("country")
|
|
||||||
registration_code = str(request.form.get("registration_code", ""))
|
|
||||||
|
|
||||||
name_len = len(name) == 0
|
|
||||||
names = Users.query.add_columns("name", "id").filter_by(name=name).first()
|
|
||||||
emails = (
|
|
||||||
Users.query.add_columns("email", "id")
|
|
||||||
.filter_by(email=email_address)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
pass_short = len(password) == 0
|
|
||||||
pass_long = len(password) > 128
|
|
||||||
valid_email = validators.validate_email(email_address)
|
|
||||||
team_name_email_check = validators.validate_email(name)
|
|
||||||
|
|
||||||
if get_config("registration_code"):
|
|
||||||
if (
|
|
||||||
registration_code.lower()
|
|
||||||
!= str(get_config("registration_code", default="")).lower()
|
|
||||||
):
|
|
||||||
errors.append("The registration code you entered was incorrect")
|
|
||||||
|
|
||||||
# Process additional user fields
|
|
||||||
fields = {}
|
|
||||||
for field in UserFields.query.all():
|
|
||||||
fields[field.id] = field
|
|
||||||
|
|
||||||
entries = {}
|
|
||||||
for field_id, field in fields.items():
|
|
||||||
value = request.form.get(f"fields[{field_id}]", "").strip()
|
|
||||||
if field.required is True and (value is None or value == ""):
|
|
||||||
errors.append("Please provide all required fields")
|
|
||||||
break
|
|
||||||
|
|
||||||
# Handle special casing of existing profile fields
|
|
||||||
if field.name.lower() == "affiliation":
|
|
||||||
affiliation = value
|
|
||||||
break
|
|
||||||
elif field.name.lower() == "website":
|
|
||||||
website = value
|
|
||||||
break
|
|
||||||
|
|
||||||
if field.field_type == "boolean":
|
|
||||||
entries[field_id] = bool(value)
|
|
||||||
else:
|
|
||||||
entries[field_id] = value
|
|
||||||
|
|
||||||
if country:
|
|
||||||
try:
|
|
||||||
validators.validate_country_code(country)
|
|
||||||
valid_country = True
|
|
||||||
except ValidationError:
|
|
||||||
valid_country = False
|
|
||||||
else:
|
|
||||||
valid_country = True
|
|
||||||
|
|
||||||
if website:
|
|
||||||
valid_website = validators.validate_url(website)
|
|
||||||
else:
|
|
||||||
valid_website = True
|
|
||||||
|
|
||||||
if affiliation:
|
|
||||||
valid_affiliation = len(affiliation) < 128
|
|
||||||
else:
|
|
||||||
valid_affiliation = True
|
|
||||||
|
|
||||||
if not valid_email:
|
|
||||||
errors.append("Please enter a valid email address")
|
|
||||||
if email.check_email_is_whitelisted(email_address) is False:
|
|
||||||
errors.append("Your email address is not from an allowed domain")
|
|
||||||
if names:
|
|
||||||
errors.append("That user name is already taken")
|
|
||||||
if team_name_email_check is True:
|
|
||||||
errors.append("Your user name cannot be an email address")
|
|
||||||
if emails:
|
|
||||||
errors.append("That email has already been used")
|
|
||||||
if pass_short:
|
|
||||||
errors.append("Pick a longer password")
|
|
||||||
if pass_long:
|
|
||||||
errors.append("Pick a shorter password")
|
|
||||||
if name_len:
|
|
||||||
errors.append("Pick a longer user name")
|
|
||||||
if valid_website is False:
|
|
||||||
errors.append("Websites must be a proper URL starting with http or https")
|
|
||||||
if valid_country is False:
|
|
||||||
errors.append("Invalid country")
|
|
||||||
if valid_affiliation is False:
|
|
||||||
errors.append("Please provide a shorter affiliation")
|
|
||||||
|
|
||||||
if len(errors) > 0:
|
|
||||||
return render_template(
|
|
||||||
"register.html",
|
|
||||||
errors=errors,
|
|
||||||
name=request.form["name"],
|
|
||||||
email=request.form["email"],
|
|
||||||
password=request.form["password"],
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
with app.app_context():
|
|
||||||
user = Users(name=name, email=email_address, password=password)
|
|
||||||
|
|
||||||
if website:
|
|
||||||
user.website = website
|
|
||||||
if affiliation:
|
|
||||||
user.affiliation = affiliation
|
|
||||||
if country:
|
|
||||||
user.country = country
|
|
||||||
|
|
||||||
db.session.add(user)
|
|
||||||
db.session.commit()
|
|
||||||
db.session.flush()
|
|
||||||
|
|
||||||
for field_id, value in entries.items():
|
|
||||||
entry = UserFieldEntries(
|
|
||||||
field_id=field_id, value=value, user_id=user.id
|
|
||||||
)
|
|
||||||
db.session.add(entry)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
login_user(user)
|
|
||||||
|
|
||||||
if request.args.get("next") and validators.is_safe_url(
|
|
||||||
request.args.get("next")
|
|
||||||
):
|
|
||||||
return redirect(request.args.get("next"))
|
|
||||||
|
|
||||||
if config.can_send_mail() and get_config(
|
|
||||||
"verify_emails"
|
|
||||||
): # Confirming users is enabled and we can send email.
|
|
||||||
log(
|
|
||||||
"registrations",
|
|
||||||
format="[{date}] {ip} - {name} registered (UNCONFIRMED) with {email}",
|
|
||||||
name=user.name,
|
|
||||||
email=user.email,
|
|
||||||
)
|
|
||||||
email.verify_email_address(user.email)
|
|
||||||
db.session.close()
|
|
||||||
return redirect(url_for("auth.confirm"))
|
|
||||||
else: # Don't care about confirming users
|
|
||||||
if (
|
|
||||||
config.can_send_mail()
|
|
||||||
): # We want to notify the user that they have registered.
|
|
||||||
email.successful_registration_notification(user.email)
|
|
||||||
|
|
||||||
log(
|
|
||||||
"registrations",
|
|
||||||
format="[{date}] {ip} - {name} registered with {email}",
|
|
||||||
name=user.name,
|
|
||||||
email=user.email,
|
|
||||||
)
|
|
||||||
db.session.close()
|
|
||||||
|
|
||||||
if is_teams_mode():
|
|
||||||
return redirect(url_for("teams.private"))
|
|
||||||
|
|
||||||
return redirect(url_for("challenges.listing"))
|
|
||||||
else:
|
|
||||||
return render_template("register.html", errors=errors)
|
|
||||||
|
|
||||||
|
|
||||||
@auth.route("/login", methods=["POST", "GET"])
|
|
||||||
@ratelimit(method="POST", limit=10, interval=5)
|
|
||||||
def login():
|
|
||||||
errors = get_errors()
|
|
||||||
if request.method == "POST":
|
|
||||||
name = request.form["name"]
|
|
||||||
|
|
||||||
# Check if the user submitted an email address or a team name
|
|
||||||
if validators.validate_email(name) is True:
|
|
||||||
user = Users.query.filter_by(email=name).first()
|
|
||||||
else:
|
|
||||||
user = Users.query.filter_by(name=name).first()
|
|
||||||
|
|
||||||
if user:
|
|
||||||
if user.password is None:
|
|
||||||
errors.append(
|
|
||||||
"Your account was registered with a 3rd party authentication provider. "
|
|
||||||
"Please try logging in with a configured authentication provider."
|
|
||||||
)
|
|
||||||
return render_template("login.html", errors=errors)
|
|
||||||
|
|
||||||
if user and verify_password(request.form["password"], user.password):
|
|
||||||
session.regenerate()
|
|
||||||
|
|
||||||
login_user(user)
|
|
||||||
log("logins", "[{date}] {ip} - {name} logged in", name=user.name)
|
|
||||||
|
|
||||||
db.session.close()
|
|
||||||
if request.args.get("next") and validators.is_safe_url(
|
|
||||||
request.args.get("next")
|
|
||||||
):
|
|
||||||
return redirect(request.args.get("next"))
|
|
||||||
return redirect(url_for("challenges.listing"))
|
|
||||||
|
|
||||||
else:
|
|
||||||
# This user exists but the password is wrong
|
|
||||||
log(
|
|
||||||
"logins",
|
|
||||||
"[{date}] {ip} - submitted invalid password for {name}",
|
|
||||||
name=user.name,
|
|
||||||
)
|
|
||||||
errors.append("Your username or password is incorrect")
|
|
||||||
db.session.close()
|
|
||||||
return render_template("login.html", errors=errors)
|
|
||||||
else:
|
|
||||||
# This user just doesn't exist
|
|
||||||
log("logins", "[{date}] {ip} - submitted invalid account information")
|
|
||||||
errors.append("Your username or password is incorrect")
|
|
||||||
db.session.close()
|
|
||||||
return render_template("login.html", errors=errors)
|
|
||||||
else:
|
|
||||||
db.session.close()
|
|
||||||
return render_template("login.html", errors=errors)
|
|
||||||
|
|
||||||
|
|
||||||
@auth.route("/oauth")
|
|
||||||
def oauth_login():
|
|
||||||
endpoint = (
|
|
||||||
get_app_config("OAUTH_AUTHORIZATION_ENDPOINT")
|
|
||||||
or get_config("oauth_authorization_endpoint")
|
|
||||||
or "https://auth.majorleaguecyber.org/oauth/authorize"
|
|
||||||
)
|
|
||||||
|
|
||||||
if get_config("user_mode") == "teams":
|
|
||||||
scope = "profile team"
|
|
||||||
else:
|
|
||||||
scope = "profile"
|
|
||||||
|
|
||||||
client_id = get_app_config("OAUTH_CLIENT_ID") or get_config("oauth_client_id")
|
|
||||||
|
|
||||||
if client_id is None:
|
|
||||||
error_for(
|
|
||||||
endpoint="auth.login",
|
|
||||||
message="OAuth Settings not configured. "
|
|
||||||
"Ask your CTF administrator to configure MajorLeagueCyber integration.",
|
|
||||||
)
|
|
||||||
return redirect(url_for("auth.login"))
|
|
||||||
|
|
||||||
redirect_url = "{endpoint}?response_type=code&client_id={client_id}&scope={scope}&state={state}".format(
|
|
||||||
endpoint=endpoint, client_id=client_id, scope=scope, state=session["nonce"]
|
|
||||||
)
|
|
||||||
return redirect(redirect_url)
|
|
||||||
|
|
||||||
|
|
||||||
@auth.route("/redirect", methods=["GET"])
|
|
||||||
@ratelimit(method="GET", limit=10, interval=60)
|
|
||||||
def oauth_redirect():
|
|
||||||
oauth_code = request.args.get("code")
|
|
||||||
state = request.args.get("state")
|
|
||||||
if session["nonce"] != state:
|
|
||||||
log("logins", "[{date}] {ip} - OAuth State validation mismatch")
|
|
||||||
error_for(endpoint="auth.login", message="OAuth State validation mismatch.")
|
|
||||||
return redirect(url_for("auth.login"))
|
|
||||||
|
|
||||||
if oauth_code:
|
|
||||||
url = (
|
|
||||||
get_app_config("OAUTH_TOKEN_ENDPOINT")
|
|
||||||
or get_config("oauth_token_endpoint")
|
|
||||||
or "https://auth.majorleaguecyber.org/oauth/token"
|
|
||||||
)
|
|
||||||
|
|
||||||
client_id = get_app_config("OAUTH_CLIENT_ID") or get_config("oauth_client_id")
|
|
||||||
client_secret = get_app_config("OAUTH_CLIENT_SECRET") or get_config(
|
|
||||||
"oauth_client_secret"
|
|
||||||
)
|
|
||||||
headers = {"content-type": "application/x-www-form-urlencoded"}
|
|
||||||
data = {
|
|
||||||
"code": oauth_code,
|
|
||||||
"client_id": client_id,
|
|
||||||
"client_secret": client_secret,
|
|
||||||
"grant_type": "authorization_code",
|
|
||||||
}
|
|
||||||
token_request = requests.post(url, data=data, headers=headers)
|
|
||||||
|
|
||||||
if token_request.status_code == requests.codes.ok:
|
|
||||||
token = token_request.json()["access_token"]
|
|
||||||
user_url = (
|
|
||||||
get_app_config("OAUTH_API_ENDPOINT")
|
|
||||||
or get_config("oauth_api_endpoint")
|
|
||||||
or "https://api.majorleaguecyber.org/user"
|
|
||||||
)
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Authorization": "Bearer " + str(token),
|
|
||||||
"Content-type": "application/json",
|
|
||||||
}
|
|
||||||
api_data = requests.get(url=user_url, headers=headers).json()
|
|
||||||
|
|
||||||
user_id = api_data["id"]
|
|
||||||
user_name = api_data["name"]
|
|
||||||
user_email = api_data["email"]
|
|
||||||
|
|
||||||
user = Users.query.filter_by(email=user_email).first()
|
|
||||||
if user is None:
|
|
||||||
# Check if we are allowing registration before creating users
|
|
||||||
if registration_visible() or mlc_registration():
|
|
||||||
user = Users(
|
|
||||||
name=user_name,
|
|
||||||
email=user_email,
|
|
||||||
oauth_id=user_id,
|
|
||||||
verified=True,
|
|
||||||
)
|
|
||||||
db.session.add(user)
|
|
||||||
db.session.commit()
|
|
||||||
else:
|
|
||||||
log("logins", "[{date}] {ip} - Public registration via MLC blocked")
|
|
||||||
error_for(
|
|
||||||
endpoint="auth.login",
|
|
||||||
message="Public registration is disabled. Please try again later.",
|
|
||||||
)
|
|
||||||
return redirect(url_for("auth.login"))
|
|
||||||
|
|
||||||
if get_config("user_mode") == TEAMS_MODE and user.team_id is None:
|
|
||||||
team_id = api_data["team"]["id"]
|
|
||||||
team_name = api_data["team"]["name"]
|
|
||||||
|
|
||||||
team = Teams.query.filter_by(oauth_id=team_id).first()
|
|
||||||
if team is None:
|
|
||||||
num_teams_limit = int(get_config("num_teams", default=0))
|
|
||||||
num_teams = Teams.query.filter_by(
|
|
||||||
banned=False, hidden=False
|
|
||||||
).count()
|
|
||||||
if num_teams_limit and num_teams >= num_teams_limit:
|
|
||||||
abort(
|
|
||||||
403,
|
|
||||||
description=f"Reached the maximum number of teams ({num_teams_limit}). Please join an existing team.",
|
|
||||||
)
|
|
||||||
|
|
||||||
team = Teams(name=team_name, oauth_id=team_id, captain_id=user.id)
|
|
||||||
db.session.add(team)
|
|
||||||
db.session.commit()
|
|
||||||
clear_team_session(team_id=team.id)
|
|
||||||
|
|
||||||
team_size_limit = get_config("team_size", default=0)
|
|
||||||
if team_size_limit and len(team.members) >= team_size_limit:
|
|
||||||
plural = "" if team_size_limit == 1 else "s"
|
|
||||||
size_error = "Teams are limited to {limit} member{plural}.".format(
|
|
||||||
limit=team_size_limit, plural=plural
|
|
||||||
)
|
|
||||||
error_for(endpoint="auth.login", message=size_error)
|
|
||||||
return redirect(url_for("auth.login"))
|
|
||||||
|
|
||||||
team.members.append(user)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
if user.oauth_id is None:
|
|
||||||
user.oauth_id = user_id
|
|
||||||
user.verified = True
|
|
||||||
db.session.commit()
|
|
||||||
clear_user_session(user_id=user.id)
|
|
||||||
|
|
||||||
login_user(user)
|
|
||||||
|
|
||||||
return redirect(url_for("challenges.listing"))
|
|
||||||
else:
|
|
||||||
log("logins", "[{date}] {ip} - OAuth token retrieval failure")
|
|
||||||
error_for(endpoint="auth.login", message="OAuth token retrieval failure.")
|
|
||||||
return redirect(url_for("auth.login"))
|
|
||||||
else:
|
|
||||||
log("logins", "[{date}] {ip} - Received redirect without OAuth code")
|
|
||||||
error_for(
|
|
||||||
endpoint="auth.login", message="Received redirect without OAuth code."
|
|
||||||
)
|
|
||||||
return redirect(url_for("auth.login"))
|
|
||||||
|
|
||||||
|
|
||||||
@auth.route("/logout")
|
|
||||||
def logout():
|
|
||||||
if current_user.authed():
|
|
||||||
logout_user()
|
|
||||||
return redirect(url_for("views.static_html"))
|
|
||||||
167
CTFd/cache/__init__.py
vendored
167
CTFd/cache/__init__.py
vendored
@@ -1,167 +0,0 @@
|
|||||||
from functools import lru_cache, wraps
|
|
||||||
from time import monotonic_ns
|
|
||||||
|
|
||||||
from flask import request
|
|
||||||
from flask_caching import Cache, make_template_fragment_key
|
|
||||||
|
|
||||||
cache = Cache()
|
|
||||||
|
|
||||||
|
|
||||||
def timed_lru_cache(timeout: int = 300, maxsize: int = 64, typed: bool = False):
|
|
||||||
"""
|
|
||||||
lru_cache implementation that includes a time based expiry
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
seconds (int): Timeout in seconds to clear the WHOLE cache, default = 5 minutes
|
|
||||||
maxsize (int): Maximum Size of the Cache
|
|
||||||
typed (bool): Same value of different type will be a different entry
|
|
||||||
|
|
||||||
Implmentation from https://gist.github.com/Morreski/c1d08a3afa4040815eafd3891e16b945?permalink_comment_id=3437689#gistcomment-3437689
|
|
||||||
"""
|
|
||||||
|
|
||||||
def wrapper_cache(func):
|
|
||||||
func = lru_cache(maxsize=maxsize, typed=typed)(func)
|
|
||||||
func.delta = timeout * 10 ** 9
|
|
||||||
func.expiration = monotonic_ns() + func.delta
|
|
||||||
|
|
||||||
@wraps(func)
|
|
||||||
def wrapped_func(*args, **kwargs):
|
|
||||||
if monotonic_ns() >= func.expiration:
|
|
||||||
func.cache_clear()
|
|
||||||
func.expiration = monotonic_ns() + func.delta
|
|
||||||
return func(*args, **kwargs)
|
|
||||||
|
|
||||||
wrapped_func.cache_info = func.cache_info
|
|
||||||
wrapped_func.cache_clear = func.cache_clear
|
|
||||||
return wrapped_func
|
|
||||||
|
|
||||||
return wrapper_cache
|
|
||||||
|
|
||||||
|
|
||||||
def make_cache_key(path=None, key_prefix="view/%s"):
|
|
||||||
"""
|
|
||||||
This function mostly emulates Flask-Caching's `make_cache_key` function so we can delete cached api responses.
|
|
||||||
Over time this function may be replaced with a cleaner custom cache implementation.
|
|
||||||
:param path:
|
|
||||||
:param key_prefix:
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
if path is None:
|
|
||||||
path = request.endpoint
|
|
||||||
cache_key = key_prefix % path
|
|
||||||
return cache_key
|
|
||||||
|
|
||||||
|
|
||||||
def clear_config():
|
|
||||||
from CTFd.utils import _get_config, get_app_config
|
|
||||||
|
|
||||||
cache.delete_memoized(_get_config)
|
|
||||||
cache.delete_memoized(get_app_config)
|
|
||||||
|
|
||||||
|
|
||||||
def clear_standings():
|
|
||||||
from CTFd.models import Users, Teams # noqa: I001
|
|
||||||
from CTFd.constants.static import CacheKeys
|
|
||||||
from CTFd.utils.scores import get_standings, get_team_standings, get_user_standings
|
|
||||||
from CTFd.api.v1.scoreboard import ScoreboardDetail, ScoreboardList
|
|
||||||
from CTFd.api import api
|
|
||||||
from CTFd.utils.user import (
|
|
||||||
get_user_score,
|
|
||||||
get_user_place,
|
|
||||||
get_team_score,
|
|
||||||
get_team_place,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Clear out the bulk standings functions
|
|
||||||
cache.delete_memoized(get_standings)
|
|
||||||
cache.delete_memoized(get_team_standings)
|
|
||||||
cache.delete_memoized(get_user_standings)
|
|
||||||
|
|
||||||
# Clear out the individual helpers for accessing score via the model
|
|
||||||
cache.delete_memoized(Users.get_score)
|
|
||||||
cache.delete_memoized(Users.get_place)
|
|
||||||
cache.delete_memoized(Teams.get_score)
|
|
||||||
cache.delete_memoized(Teams.get_place)
|
|
||||||
|
|
||||||
# Clear the Jinja Attrs constants
|
|
||||||
cache.delete_memoized(get_user_score)
|
|
||||||
cache.delete_memoized(get_user_place)
|
|
||||||
cache.delete_memoized(get_team_score)
|
|
||||||
cache.delete_memoized(get_team_place)
|
|
||||||
|
|
||||||
# Clear out HTTP request responses
|
|
||||||
cache.delete(make_cache_key(path=api.name + "." + ScoreboardList.endpoint))
|
|
||||||
cache.delete(make_cache_key(path=api.name + "." + ScoreboardDetail.endpoint))
|
|
||||||
cache.delete_memoized(ScoreboardList.get)
|
|
||||||
|
|
||||||
# Clear out scoreboard templates
|
|
||||||
cache.delete(make_template_fragment_key(CacheKeys.PUBLIC_SCOREBOARD_TABLE))
|
|
||||||
|
|
||||||
|
|
||||||
def clear_challenges():
|
|
||||||
from CTFd.utils.challenges import get_all_challenges # noqa: I001
|
|
||||||
from CTFd.utils.challenges import get_solves_for_challenge_id
|
|
||||||
from CTFd.utils.challenges import get_solve_ids_for_user_id
|
|
||||||
from CTFd.utils.challenges import get_solve_counts_for_challenges
|
|
||||||
|
|
||||||
cache.delete_memoized(get_all_challenges)
|
|
||||||
cache.delete_memoized(get_solves_for_challenge_id)
|
|
||||||
cache.delete_memoized(get_solve_ids_for_user_id)
|
|
||||||
cache.delete_memoized(get_solve_counts_for_challenges)
|
|
||||||
|
|
||||||
|
|
||||||
def clear_pages():
|
|
||||||
from CTFd.utils.config.pages import get_page, get_pages
|
|
||||||
|
|
||||||
cache.delete_memoized(get_pages)
|
|
||||||
cache.delete_memoized(get_page)
|
|
||||||
|
|
||||||
|
|
||||||
def clear_user_recent_ips(user_id):
|
|
||||||
from CTFd.utils.user import get_user_recent_ips
|
|
||||||
|
|
||||||
cache.delete_memoized(get_user_recent_ips, user_id=user_id)
|
|
||||||
|
|
||||||
|
|
||||||
def clear_user_session(user_id):
|
|
||||||
from CTFd.utils.user import ( # noqa: I001
|
|
||||||
get_user_attrs,
|
|
||||||
get_user_place,
|
|
||||||
get_user_score,
|
|
||||||
get_user_recent_ips,
|
|
||||||
)
|
|
||||||
|
|
||||||
cache.delete_memoized(get_user_attrs, user_id=user_id)
|
|
||||||
cache.delete_memoized(get_user_place, user_id=user_id)
|
|
||||||
cache.delete_memoized(get_user_score, user_id=user_id)
|
|
||||||
cache.delete_memoized(get_user_recent_ips, user_id=user_id)
|
|
||||||
|
|
||||||
|
|
||||||
def clear_all_user_sessions():
|
|
||||||
from CTFd.utils.user import ( # noqa: I001
|
|
||||||
get_user_attrs,
|
|
||||||
get_user_place,
|
|
||||||
get_user_score,
|
|
||||||
get_user_recent_ips,
|
|
||||||
)
|
|
||||||
|
|
||||||
cache.delete_memoized(get_user_attrs)
|
|
||||||
cache.delete_memoized(get_user_place)
|
|
||||||
cache.delete_memoized(get_user_score)
|
|
||||||
cache.delete_memoized(get_user_recent_ips)
|
|
||||||
|
|
||||||
|
|
||||||
def clear_team_session(team_id):
|
|
||||||
from CTFd.utils.user import get_team_attrs, get_team_place, get_team_score
|
|
||||||
|
|
||||||
cache.delete_memoized(get_team_attrs, team_id=team_id)
|
|
||||||
cache.delete_memoized(get_team_place, team_id=team_id)
|
|
||||||
cache.delete_memoized(get_team_score, team_id=team_id)
|
|
||||||
|
|
||||||
|
|
||||||
def clear_all_team_sessions():
|
|
||||||
from CTFd.utils.user import get_team_attrs, get_team_place, get_team_score
|
|
||||||
|
|
||||||
cache.delete_memoized(get_team_attrs)
|
|
||||||
cache.delete_memoized(get_team_place)
|
|
||||||
cache.delete_memoized(get_team_score)
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
from flask import Blueprint, redirect, render_template, request, url_for
|
|
||||||
|
|
||||||
from CTFd.constants.config import ChallengeVisibilityTypes, Configs
|
|
||||||
from CTFd.utils.config import is_teams_mode
|
|
||||||
from CTFd.utils.dates import ctf_ended, ctf_paused, ctf_started
|
|
||||||
from CTFd.utils.decorators import (
|
|
||||||
during_ctf_time_only,
|
|
||||||
require_complete_profile,
|
|
||||||
require_verified_emails,
|
|
||||||
)
|
|
||||||
from CTFd.utils.decorators.visibility import check_challenge_visibility
|
|
||||||
from CTFd.utils.helpers import get_errors, get_infos
|
|
||||||
from CTFd.utils.user import authed, get_current_team
|
|
||||||
|
|
||||||
challenges = Blueprint("challenges", __name__)
|
|
||||||
|
|
||||||
|
|
||||||
@challenges.route("/challenges", methods=["GET"])
|
|
||||||
@require_complete_profile
|
|
||||||
@during_ctf_time_only
|
|
||||||
@require_verified_emails
|
|
||||||
@check_challenge_visibility
|
|
||||||
def listing():
|
|
||||||
if (
|
|
||||||
Configs.challenge_visibility == ChallengeVisibilityTypes.PUBLIC
|
|
||||||
and authed() is False
|
|
||||||
):
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
if is_teams_mode() and get_current_team() is None:
|
|
||||||
return redirect(url_for("teams.private", next=request.full_path))
|
|
||||||
|
|
||||||
infos = get_infos()
|
|
||||||
errors = get_errors()
|
|
||||||
|
|
||||||
if Configs.challenge_visibility == ChallengeVisibilityTypes.ADMINS:
|
|
||||||
infos.append("Challenge Visibility is set to Admins Only")
|
|
||||||
|
|
||||||
if ctf_started() is False:
|
|
||||||
errors.append(f"{Configs.ctf_name} has not started yet")
|
|
||||||
|
|
||||||
if ctf_paused() is True:
|
|
||||||
infos.append(f"{Configs.ctf_name} is paused")
|
|
||||||
|
|
||||||
if ctf_ended() is True:
|
|
||||||
infos.append(f"{Configs.ctf_name} has ended")
|
|
||||||
|
|
||||||
return render_template("challenges.html", infos=infos, errors=errors)
|
|
||||||
280
CTFd/config.ini
280
CTFd/config.ini
@@ -1,280 +0,0 @@
|
|||||||
# CTFd Configuration File
|
|
||||||
#
|
|
||||||
# Use this file to configure aspects of how CTFd behaves. Additional attributes can be specified for
|
|
||||||
# plugins and other additional behavior.
|
|
||||||
#
|
|
||||||
# If a configuration item is specified but left empty, CTFd will do the following:
|
|
||||||
#
|
|
||||||
# 1. Look for an environment variable under the same name and use that value if found
|
|
||||||
# 2. Use a default value specified in it's own internal configuration
|
|
||||||
# 3. Use a null value (i.e. None) or empty string for the configuration value
|
|
||||||
|
|
||||||
|
|
||||||
[server]
|
|
||||||
# SECRET_KEY:
|
|
||||||
# The secret value used to creation sessions and sign strings. This should be set to a random string. In the
|
|
||||||
# interest of ease, CTFd will automatically create a secret key file for you. If you wish to add this secret key
|
|
||||||
# to your instance you should hard code this value to a random static value.
|
|
||||||
#
|
|
||||||
# You can also remove .ctfd_secret_key from the .gitignore file and commit this file into whatever repository
|
|
||||||
# you are using.
|
|
||||||
#
|
|
||||||
# http://flask.pocoo.org/docs/latest/quickstart/#sessions
|
|
||||||
SECRET_KEY =
|
|
||||||
|
|
||||||
# DATABASE_URL
|
|
||||||
# The URI that specifies the username, password, hostname, port, and database of the server
|
|
||||||
# used to hold the CTFd database.
|
|
||||||
#
|
|
||||||
# If neither this setting nor `DATABASE_HOST` is specified, CTFd will automatically create a SQLite database for you to use
|
|
||||||
# e.g. mysql+pymysql://root:<YOUR_PASSWORD_HERE>@localhost/ctfd
|
|
||||||
DATABASE_URL =
|
|
||||||
|
|
||||||
# DATABASE_HOST
|
|
||||||
# The hostname of the database server used to hold the CTFd database.
|
|
||||||
# If `DATABASE_URL` is set, this setting will have no effect.
|
|
||||||
#
|
|
||||||
# This option, along with the other `DATABASE_*` options, are an alternative to specifying all connection details in the single `DATABASE_URL`.
|
|
||||||
# If neither this setting nor `DATABASE_URL` is specified, CTFd will automatically create a SQLite database for you to use.
|
|
||||||
DATABASE_HOST =
|
|
||||||
|
|
||||||
# DATABASE_PROTOCOL
|
|
||||||
# The protocol used to access the database server, if `DATABASE_HOST` is set. Defaults to `mysql+pymysql`.
|
|
||||||
DATABASE_PROTOCOL =
|
|
||||||
|
|
||||||
# DATABASE_USER
|
|
||||||
# The username used to access the database server, if `DATABASE_HOST` is set. Defaults to `ctfd`.
|
|
||||||
DATABASE_USER =
|
|
||||||
|
|
||||||
# DATABASE_PASSWORD
|
|
||||||
# The password used to access the database server, if `DATABASE_HOST` is set.
|
|
||||||
DATABASE_PASSWORD =
|
|
||||||
|
|
||||||
# DATABASE_PORT
|
|
||||||
# The port used to access the database server, if `DATABASE_HOST` is set.
|
|
||||||
DATABASE_PORT =
|
|
||||||
|
|
||||||
# DATABASE_NAME
|
|
||||||
# The name of the database to access on the database server, if `DATABASE_HOST` is set. Defaults to `ctfd`.
|
|
||||||
DATABASE_NAME =
|
|
||||||
|
|
||||||
# REDIS_URL
|
|
||||||
# The URL to connect to a Redis server. If neither this setting nor `REDIS_HOST` is specified,
|
|
||||||
# CTFd will use the .data folder as a filesystem cache.
|
|
||||||
#
|
|
||||||
# e.g. redis://user:password@localhost:6379
|
|
||||||
# http://pythonhosted.org/Flask-Caching/#configuring-flask-caching
|
|
||||||
REDIS_URL =
|
|
||||||
|
|
||||||
# REDIS_HOST
|
|
||||||
# The hostname of the Redis server to connect to.
|
|
||||||
# If `REDIS_URL` is set, this setting will have no effect.
|
|
||||||
#
|
|
||||||
# This option, along with the other `REDIS_*` options, are an alternative to specifying all connection details in the single `REDIS_URL`.
|
|
||||||
# If neither this setting nor `REDIS_URL` is specified, CTFd will use the .data folder as a filesystem cache.
|
|
||||||
REDIS_HOST =
|
|
||||||
|
|
||||||
# REDIS_PROTOCOL
|
|
||||||
# The protocol used to access the Redis server, if `REDIS_HOST` is set. Defaults to `redis`.
|
|
||||||
#
|
|
||||||
# Note that the `unix` protocol is not supported here; use `REDIS_URL` instead.
|
|
||||||
REDIS_PROTOCOL =
|
|
||||||
|
|
||||||
# REDIS_USER
|
|
||||||
# The username used to access the Redis server, if `REDIS_HOST` is set.
|
|
||||||
REDIS_USER =
|
|
||||||
|
|
||||||
# REDIS_PASSWORD
|
|
||||||
# The password used to access the Redis server, if `REDIS_HOST` is set.
|
|
||||||
REDIS_PASSWORD =
|
|
||||||
|
|
||||||
# REDIS_PORT
|
|
||||||
# The port used to access the Redis server, if `REDIS_HOST` is set.
|
|
||||||
REDIS_PORT =
|
|
||||||
|
|
||||||
# REDIS_DB
|
|
||||||
# The index of the Redis database to access, if `REDIS_HOST` is set.
|
|
||||||
REDIS_DB =
|
|
||||||
|
|
||||||
[security]
|
|
||||||
# SESSION_COOKIE_HTTPONLY
|
|
||||||
# Controls if cookies should be set with the HttpOnly flag. Defaults to True.
|
|
||||||
SESSION_COOKIE_HTTPONLY = true
|
|
||||||
|
|
||||||
# SESSION_COOKIE_SAMESITE
|
|
||||||
# Controls the SameSite attribute on session cookies. Can be Lax or Strict.
|
|
||||||
# Should be left as Lax unless the implications are well understood
|
|
||||||
SESSION_COOKIE_SAMESITE = Lax
|
|
||||||
|
|
||||||
# PERMANENT_SESSION_LIFETIME
|
|
||||||
# The lifetime of a session. The default is 604800 seconds (7 days).
|
|
||||||
PERMANENT_SESSION_LIFETIME = 604800
|
|
||||||
|
|
||||||
[email]
|
|
||||||
# MAILFROM_ADDR
|
|
||||||
# The email address that emails are sent from if not overridden in the configuration panel.
|
|
||||||
MAILFROM_ADDR =
|
|
||||||
|
|
||||||
# MAIL_SERVER
|
|
||||||
# The mail server that emails are sent from if not overriden in the configuration panel.
|
|
||||||
MAIL_SERVER =
|
|
||||||
|
|
||||||
# MAIL_PORT
|
|
||||||
# The mail port that emails are sent from if not overriden in the configuration panel.
|
|
||||||
MAIL_PORT =
|
|
||||||
|
|
||||||
# MAIL_USEAUTH
|
|
||||||
# Whether or not to use username and password to authenticate to the SMTP server
|
|
||||||
MAIL_USEAUTH =
|
|
||||||
|
|
||||||
# MAIL_USERNAME
|
|
||||||
# The username used to authenticate to the SMTP server if MAIL_USEAUTH is defined
|
|
||||||
MAIL_USERNAME =
|
|
||||||
|
|
||||||
# MAIL_PASSWORD
|
|
||||||
# The password used to authenticate to the SMTP server if MAIL_USEAUTH is defined
|
|
||||||
MAIL_PASSWORD =
|
|
||||||
|
|
||||||
# MAIL_TLS
|
|
||||||
# Whether to connect to the SMTP server over TLS
|
|
||||||
MAIL_TLS =
|
|
||||||
|
|
||||||
# MAIL_SSL
|
|
||||||
# Whether to connect to the SMTP server over SSL
|
|
||||||
MAIL_SSL =
|
|
||||||
|
|
||||||
# MAILSENDER_ADDR
|
|
||||||
# The email address that is responsible for the transmission of emails.
|
|
||||||
# This is very often the MAILFROM_ADDR value but can be specified if your email
|
|
||||||
# is delivered by a different domain than what's specified in your MAILFROM_ADDR.
|
|
||||||
# If this isn't specified, the MAILFROM_ADDR value is used.
|
|
||||||
# It is fairly rare to need to set this value.
|
|
||||||
MAILSENDER_ADDR =
|
|
||||||
|
|
||||||
# MAILGUN_API_KEY
|
|
||||||
# Mailgun API key to send email over Mailgun. As of CTFd v3, Mailgun integration is deprecated.
|
|
||||||
# Installations using the Mailgun API should migrate over to SMTP settings.
|
|
||||||
MAILGUN_API_KEY =
|
|
||||||
|
|
||||||
# MAILGUN_BASE_URL
|
|
||||||
# Mailgun base url to send email over Mailgun. As of CTFd v3, Mailgun integration is deprecated.
|
|
||||||
# Installations using the Mailgun API should migrate over to SMTP settings.
|
|
||||||
MAILGUN_BASE_URL =
|
|
||||||
|
|
||||||
# MAIL_PROVIDER
|
|
||||||
# Specifies the email provider that CTFd will use to send email.
|
|
||||||
# By default CTFd will automatically detect the correct email provider based on the other settings
|
|
||||||
# specified here or in the configuration panel. This setting can be used to force a specific provider.
|
|
||||||
MAIL_PROVIDER =
|
|
||||||
|
|
||||||
[uploads]
|
|
||||||
# UPLOAD_PROVIDER
|
|
||||||
# Specifies the service that CTFd should use to store files.
|
|
||||||
# Can be set to filesystem or s3
|
|
||||||
UPLOAD_PROVIDER =
|
|
||||||
|
|
||||||
# UPLOAD_FOLDER
|
|
||||||
# The location where files are uploaded under the filesystem uploader.
|
|
||||||
# The default destination is the CTFd/uploads folder.
|
|
||||||
UPLOAD_FOLDER =
|
|
||||||
|
|
||||||
# AWS_ACCESS_KEY_ID
|
|
||||||
# AWS access token used to authenticate to the S3 bucket. Only used under the s3 uploader.
|
|
||||||
AWS_ACCESS_KEY_ID =
|
|
||||||
|
|
||||||
# AWS_SECRET_ACCESS_KEY
|
|
||||||
# AWS secret token used to authenticate to the S3 bucket. Only used under the s3 uploader.
|
|
||||||
AWS_SECRET_ACCESS_KEY =
|
|
||||||
|
|
||||||
# AWS_S3_BUCKET
|
|
||||||
# The unique identifier for your S3 bucket. Only used under the s3 uploader.
|
|
||||||
AWS_S3_BUCKET =
|
|
||||||
|
|
||||||
# AWS_S3_ENDPOINT_URL
|
|
||||||
# A URL pointing to a custom S3 implementation. Only used under the s3 uploader.
|
|
||||||
AWS_S3_ENDPOINT_URL =
|
|
||||||
|
|
||||||
# AWS_S3_REGION
|
|
||||||
# The aws region that hosts your bucket. Only used in the s3 uploader.
|
|
||||||
AWS_S3_REGION =
|
|
||||||
|
|
||||||
[logs]
|
|
||||||
# LOG_FOLDER
|
|
||||||
# The location where logs are written. These are the logs for CTFd key submissions, registrations, and logins. The default location is the CTFd/logs folder.
|
|
||||||
LOG_FOLDER =
|
|
||||||
|
|
||||||
[optional]
|
|
||||||
# REVERSE_PROXY
|
|
||||||
# Specifies whether CTFd is behind a reverse proxy or not. Set to true if using a reverse proxy like nginx.
|
|
||||||
# You can also specify a comma seperated set of numbers specifying the reverse proxy configuration settings.
|
|
||||||
# See https://werkzeug.palletsprojects.com/en/0.15.x/middleware/proxy_fix/#werkzeug.middleware.proxy_fix.ProxyFix.
|
|
||||||
# For example to configure `x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1` specify `1,1,1,1,1`.
|
|
||||||
# If you specify `true` CTFd will default to the above behavior with all proxy settings set to 1.
|
|
||||||
REVERSE_PROXY =
|
|
||||||
|
|
||||||
# THEME_FALLBACK
|
|
||||||
# Specifies whether CTFd will fallback to the default "core" theme for missing pages/content. Useful for developing themes or using incomplete themes.
|
|
||||||
# Defaults to true.
|
|
||||||
THEME_FALLBACK =
|
|
||||||
|
|
||||||
# TEMPLATES_AUTO_RELOAD
|
|
||||||
# Specifies whether Flask should check for modifications to templates and reload them automatically. Defaults to true.
|
|
||||||
TEMPLATES_AUTO_RELOAD =
|
|
||||||
|
|
||||||
# SQLALCHEMY_TRACK_MODIFICATIONS
|
|
||||||
# Automatically disabled to suppress warnings and save memory.
|
|
||||||
# You should only enable this if you need it.
|
|
||||||
# Defaults to false.
|
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS =
|
|
||||||
|
|
||||||
# SWAGGER_UI
|
|
||||||
# Enable the Swagger UI endpoint at /api/v1/
|
|
||||||
SWAGGER_UI =
|
|
||||||
|
|
||||||
# UPDATE_CHECK
|
|
||||||
# Specifies whether or not CTFd will check whether or not there is a new version of CTFd. Defaults True.
|
|
||||||
UPDATE_CHECK =
|
|
||||||
|
|
||||||
# APPLICATION_ROOT
|
|
||||||
# Specifies what path CTFd is mounted under. It can be used to run CTFd in a subdirectory.
|
|
||||||
# Example: /ctfd
|
|
||||||
APPLICATION_ROOT =
|
|
||||||
|
|
||||||
# SERVER_SENT_EVENTS
|
|
||||||
# Specifies whether or not to enable the Server-Sent Events based Notifications system.
|
|
||||||
# Defaults to true
|
|
||||||
SERVER_SENT_EVENTS =
|
|
||||||
|
|
||||||
# HTML_SANITIZATION
|
|
||||||
# Specifies whether CTFd should sanitize HTML content
|
|
||||||
# Defaults to false
|
|
||||||
HTML_SANITIZATION =
|
|
||||||
|
|
||||||
# SQLALCHEMY_MAX_OVERFLOW
|
|
||||||
# Specifies the max_overflow setting for SQLAlchemy's Engine
|
|
||||||
# https://docs.sqlalchemy.org/en/13/core/engines.html#sqlalchemy.create_engine
|
|
||||||
# https://flask-sqlalchemy.palletsprojects.com/en/2.x/config/#configuration-keys
|
|
||||||
SQLALCHEMY_MAX_OVERFLOW =
|
|
||||||
|
|
||||||
# SQLALCHEMY_POOL_PRE_PING
|
|
||||||
# Specifies the pool_pre_ping setting for SQLAlchemy's Engine
|
|
||||||
# https://docs.sqlalchemy.org/en/13/core/engines.html#sqlalchemy.create_engine
|
|
||||||
# https://flask-sqlalchemy.palletsprojects.com/en/2.x/config/#configuration-keys
|
|
||||||
SQLALCHEMY_POOL_PRE_PING =
|
|
||||||
|
|
||||||
# SAFE_MODE
|
|
||||||
# If SAFE_MODE is enabled, CTFd will not load any plugins which may alleviate issues preventing CTFd from starting
|
|
||||||
# Defaults to false
|
|
||||||
SAFE_MODE =
|
|
||||||
|
|
||||||
[oauth]
|
|
||||||
# OAUTH_CLIENT_ID
|
|
||||||
# Register an event at https://majorleaguecyber.org/ and use the Client ID here
|
|
||||||
OAUTH_CLIENT_ID =
|
|
||||||
|
|
||||||
# OAUTH_CLIENT_ID
|
|
||||||
# Register an event at https://majorleaguecyber.org/ and use the Client Secret here
|
|
||||||
OAUTH_CLIENT_SECRET =
|
|
||||||
|
|
||||||
[extra]
|
|
||||||
# The extra section can be used to specify additional values to be loaded into CTFd's configuration
|
|
||||||
266
CTFd/config.py
266
CTFd/config.py
@@ -1,266 +0,0 @@
|
|||||||
import configparser
|
|
||||||
import os
|
|
||||||
from distutils.util import strtobool
|
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
from sqlalchemy.engine.url import URL
|
|
||||||
|
|
||||||
|
|
||||||
class EnvInterpolation(configparser.BasicInterpolation):
|
|
||||||
"""Interpolation which expands environment variables in values."""
|
|
||||||
|
|
||||||
def before_get(self, parser, section, option, value, defaults):
|
|
||||||
value = super().before_get(parser, section, option, value, defaults)
|
|
||||||
envvar = os.getenv(option)
|
|
||||||
if value == "" and envvar:
|
|
||||||
return process_string_var(envvar)
|
|
||||||
else:
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def process_string_var(value):
|
|
||||||
if value == "":
|
|
||||||
return None
|
|
||||||
|
|
||||||
if value.isdigit():
|
|
||||||
return int(value)
|
|
||||||
elif value.replace(".", "", 1).isdigit():
|
|
||||||
return float(value)
|
|
||||||
|
|
||||||
try:
|
|
||||||
return bool(strtobool(value))
|
|
||||||
except ValueError:
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def process_boolean_str(value):
|
|
||||||
if type(value) is bool:
|
|
||||||
return value
|
|
||||||
|
|
||||||
if value is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if value == "":
|
|
||||||
return None
|
|
||||||
|
|
||||||
return bool(strtobool(value))
|
|
||||||
|
|
||||||
|
|
||||||
def empty_str_cast(value, default=None):
|
|
||||||
if value == "":
|
|
||||||
return default
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def gen_secret_key():
|
|
||||||
# Attempt to read the secret from the secret file
|
|
||||||
# This will fail if the secret has not been written
|
|
||||||
try:
|
|
||||||
with open(".ctfd_secret_key", "rb") as secret:
|
|
||||||
key = secret.read()
|
|
||||||
except OSError:
|
|
||||||
key = None
|
|
||||||
|
|
||||||
if not key:
|
|
||||||
key = os.urandom(64)
|
|
||||||
# Attempt to write the secret file
|
|
||||||
# This will fail if the filesystem is read-only
|
|
||||||
try:
|
|
||||||
with open(".ctfd_secret_key", "wb") as secret:
|
|
||||||
secret.write(key)
|
|
||||||
secret.flush()
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return key
|
|
||||||
|
|
||||||
|
|
||||||
config_ini = configparser.ConfigParser(interpolation=EnvInterpolation())
|
|
||||||
config_ini.optionxform = str # Makes the key value case-insensitive
|
|
||||||
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.ini")
|
|
||||||
config_ini.read(path)
|
|
||||||
|
|
||||||
|
|
||||||
# fmt: off
|
|
||||||
class ServerConfig(object):
|
|
||||||
SECRET_KEY: str = empty_str_cast(config_ini["server"]["SECRET_KEY"]) \
|
|
||||||
or gen_secret_key()
|
|
||||||
|
|
||||||
DATABASE_URL: str = empty_str_cast(config_ini["server"]["DATABASE_URL"])
|
|
||||||
if not DATABASE_URL:
|
|
||||||
if empty_str_cast(config_ini["server"]["DATABASE_HOST"]) is not None:
|
|
||||||
# construct URL from individual variables
|
|
||||||
DATABASE_URL = str(URL(
|
|
||||||
drivername=empty_str_cast(config_ini["server"]["DATABASE_PROTOCOL"]) or "mysql+pymysql",
|
|
||||||
username=empty_str_cast(config_ini["server"]["DATABASE_USER"]) or "ctfd",
|
|
||||||
password=empty_str_cast(config_ini["server"]["DATABASE_PASSWORD"]),
|
|
||||||
host=empty_str_cast(config_ini["server"]["DATABASE_HOST"]),
|
|
||||||
port=empty_str_cast(config_ini["server"]["DATABASE_PORT"]),
|
|
||||||
database=empty_str_cast(config_ini["server"]["DATABASE_NAME"]) or "ctfd",
|
|
||||||
))
|
|
||||||
else:
|
|
||||||
# default to local SQLite DB
|
|
||||||
DATABASE_URL = f"sqlite:///{os.path.dirname(os.path.abspath(__file__))}/ctfd.db"
|
|
||||||
|
|
||||||
REDIS_URL: str = empty_str_cast(config_ini["server"]["REDIS_URL"])
|
|
||||||
|
|
||||||
REDIS_HOST: str = empty_str_cast(config_ini["server"]["REDIS_HOST"])
|
|
||||||
REDIS_PROTOCOL: str = empty_str_cast(config_ini["server"]["REDIS_PROTOCOL"]) or "redis"
|
|
||||||
REDIS_USER: str = empty_str_cast(config_ini["server"]["REDIS_USER"])
|
|
||||||
REDIS_PASSWORD: str = empty_str_cast(config_ini["server"]["REDIS_PASSWORD"])
|
|
||||||
REDIS_PORT: int = empty_str_cast(config_ini["server"]["REDIS_PORT"]) or 6379
|
|
||||||
REDIS_DB: int = empty_str_cast(config_ini["server"]["REDIS_DB"]) or 0
|
|
||||||
|
|
||||||
if REDIS_URL or REDIS_HOST is None:
|
|
||||||
CACHE_REDIS_URL = REDIS_URL
|
|
||||||
else:
|
|
||||||
# construct URL from individual variables
|
|
||||||
CACHE_REDIS_URL = f"{REDIS_PROTOCOL}://"
|
|
||||||
if REDIS_USER:
|
|
||||||
CACHE_REDIS_URL += REDIS_USER
|
|
||||||
if REDIS_PASSWORD:
|
|
||||||
CACHE_REDIS_URL += f":{REDIS_PASSWORD}"
|
|
||||||
CACHE_REDIS_URL += f"@{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}"
|
|
||||||
|
|
||||||
SQLALCHEMY_DATABASE_URI = DATABASE_URL
|
|
||||||
if CACHE_REDIS_URL:
|
|
||||||
CACHE_TYPE: str = "redis"
|
|
||||||
else:
|
|
||||||
CACHE_TYPE: str = "filesystem"
|
|
||||||
CACHE_DIR: str = os.path.join(
|
|
||||||
os.path.dirname(__file__), os.pardir, ".data", "filesystem_cache"
|
|
||||||
)
|
|
||||||
# Override the threshold of cached values on the filesystem. The default is 500. Don't change unless you know what you're doing.
|
|
||||||
CACHE_THRESHOLD: int = 0
|
|
||||||
|
|
||||||
# === SECURITY ===
|
|
||||||
SESSION_COOKIE_HTTPONLY: bool = config_ini["security"].getboolean("SESSION_COOKIE_HTTPONLY", fallback=True)
|
|
||||||
|
|
||||||
SESSION_COOKIE_SAMESITE: str = empty_str_cast(config_ini["security"]["SESSION_COOKIE_SAMESITE"]) \
|
|
||||||
or "Lax"
|
|
||||||
|
|
||||||
PERMANENT_SESSION_LIFETIME: int = config_ini["security"].getint("PERMANENT_SESSION_LIFETIME") \
|
|
||||||
or 604800
|
|
||||||
|
|
||||||
"""
|
|
||||||
TRUSTED_PROXIES:
|
|
||||||
Defines a set of regular expressions used for finding a user's IP address if the CTFd instance
|
|
||||||
is behind a proxy. If you are running a CTF and users are on the same network as you, you may choose to remove
|
|
||||||
some proxies from the list.
|
|
||||||
|
|
||||||
CTFd only uses IP addresses for cursory tracking purposes. It is ill-advised to do anything complicated based
|
|
||||||
solely on IP addresses unless you know what you are doing.
|
|
||||||
"""
|
|
||||||
TRUSTED_PROXIES = [
|
|
||||||
r"^127\.0\.0\.1$",
|
|
||||||
# Remove the following proxies if you do not trust the local network
|
|
||||||
# For example if you are running a CTF on your laptop and the teams are
|
|
||||||
# all on the same network
|
|
||||||
r"^::1$",
|
|
||||||
r"^fc00:",
|
|
||||||
r"^10\.",
|
|
||||||
r"^172\.(1[6-9]|2[0-9]|3[0-1])\.",
|
|
||||||
r"^192\.168\.",
|
|
||||||
]
|
|
||||||
|
|
||||||
# === EMAIL ===
|
|
||||||
MAILFROM_ADDR: str = config_ini["email"]["MAILFROM_ADDR"] \
|
|
||||||
or "noreply@examplectf.com"
|
|
||||||
|
|
||||||
MAIL_SERVER: str = empty_str_cast(config_ini["email"]["MAIL_SERVER"])
|
|
||||||
|
|
||||||
MAIL_PORT: int = empty_str_cast(config_ini["email"]["MAIL_PORT"])
|
|
||||||
|
|
||||||
MAIL_USEAUTH: bool = process_boolean_str(config_ini["email"]["MAIL_USEAUTH"])
|
|
||||||
|
|
||||||
MAIL_USERNAME: str = empty_str_cast(config_ini["email"]["MAIL_USERNAME"])
|
|
||||||
|
|
||||||
MAIL_PASSWORD: str = empty_str_cast(config_ini["email"]["MAIL_PASSWORD"])
|
|
||||||
|
|
||||||
MAIL_TLS: bool = process_boolean_str(config_ini["email"]["MAIL_TLS"])
|
|
||||||
|
|
||||||
MAIL_SSL: bool = process_boolean_str(config_ini["email"]["MAIL_SSL"])
|
|
||||||
|
|
||||||
MAILSENDER_ADDR: str = empty_str_cast(config_ini["email"]["MAILSENDER_ADDR"])
|
|
||||||
|
|
||||||
MAILGUN_API_KEY: str = empty_str_cast(config_ini["email"]["MAILGUN_API_KEY"])
|
|
||||||
|
|
||||||
MAILGUN_BASE_URL: str = empty_str_cast(config_ini["email"]["MAILGUN_API_KEY"])
|
|
||||||
|
|
||||||
MAIL_PROVIDER: str = empty_str_cast(config_ini["email"].get("MAIL_PROVIDER"))
|
|
||||||
|
|
||||||
# === LOGS ===
|
|
||||||
LOG_FOLDER: str = empty_str_cast(config_ini["logs"]["LOG_FOLDER"]) \
|
|
||||||
or os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
|
|
||||||
|
|
||||||
# === UPLOADS ===
|
|
||||||
UPLOAD_PROVIDER: str = empty_str_cast(config_ini["uploads"]["UPLOAD_PROVIDER"]) \
|
|
||||||
or "filesystem"
|
|
||||||
|
|
||||||
UPLOAD_FOLDER: str = empty_str_cast(config_ini["uploads"]["UPLOAD_FOLDER"]) \
|
|
||||||
or os.path.join(os.path.dirname(os.path.abspath(__file__)), "uploads")
|
|
||||||
|
|
||||||
if UPLOAD_PROVIDER == "s3":
|
|
||||||
AWS_ACCESS_KEY_ID: str = empty_str_cast(config_ini["uploads"]["AWS_ACCESS_KEY_ID"])
|
|
||||||
|
|
||||||
AWS_SECRET_ACCESS_KEY: str = empty_str_cast(config_ini["uploads"]["AWS_SECRET_ACCESS_KEY"])
|
|
||||||
|
|
||||||
AWS_S3_BUCKET: str = empty_str_cast(config_ini["uploads"]["AWS_S3_BUCKET"])
|
|
||||||
|
|
||||||
AWS_S3_ENDPOINT_URL: str = empty_str_cast(config_ini["uploads"]["AWS_S3_ENDPOINT_URL"])
|
|
||||||
|
|
||||||
AWS_S3_REGION: str = empty_str_cast(config_ini["uploads"]["AWS_S3_REGION"])
|
|
||||||
|
|
||||||
# === OPTIONAL ===
|
|
||||||
REVERSE_PROXY: Union[str, bool] = empty_str_cast(config_ini["optional"]["REVERSE_PROXY"], default=False)
|
|
||||||
|
|
||||||
TEMPLATES_AUTO_RELOAD: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["TEMPLATES_AUTO_RELOAD"], default=True))
|
|
||||||
|
|
||||||
THEME_FALLBACK: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["THEME_FALLBACK"], default=True))
|
|
||||||
|
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["SQLALCHEMY_TRACK_MODIFICATIONS"], default=False))
|
|
||||||
|
|
||||||
SWAGGER_UI: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["SWAGGER_UI"], default=False))
|
|
||||||
|
|
||||||
SWAGGER_UI_ENDPOINT: str = "/" if SWAGGER_UI else None
|
|
||||||
|
|
||||||
UPDATE_CHECK: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["UPDATE_CHECK"], default=True))
|
|
||||||
|
|
||||||
APPLICATION_ROOT: str = empty_str_cast(config_ini["optional"]["APPLICATION_ROOT"], default="/")
|
|
||||||
|
|
||||||
SERVER_SENT_EVENTS: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["SERVER_SENT_EVENTS"], default=True))
|
|
||||||
|
|
||||||
HTML_SANITIZATION: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["HTML_SANITIZATION"], default=False))
|
|
||||||
|
|
||||||
SAFE_MODE: bool = process_boolean_str(empty_str_cast(config_ini["optional"].get("SAFE_MODE", False), default=False))
|
|
||||||
|
|
||||||
if DATABASE_URL.startswith("sqlite") is False:
|
|
||||||
SQLALCHEMY_ENGINE_OPTIONS = {
|
|
||||||
"max_overflow": int(empty_str_cast(config_ini["optional"]["SQLALCHEMY_MAX_OVERFLOW"], default=20)), # noqa: E131
|
|
||||||
"pool_pre_ping": empty_str_cast(config_ini["optional"]["SQLALCHEMY_POOL_PRE_PING"], default=True), # noqa: E131
|
|
||||||
}
|
|
||||||
|
|
||||||
# === OAUTH ===
|
|
||||||
OAUTH_CLIENT_ID: str = empty_str_cast(config_ini["oauth"]["OAUTH_CLIENT_ID"])
|
|
||||||
OAUTH_CLIENT_SECRET: str = empty_str_cast(config_ini["oauth"]["OAUTH_CLIENT_SECRET"])
|
|
||||||
# fmt: on
|
|
||||||
|
|
||||||
|
|
||||||
class TestingConfig(ServerConfig):
|
|
||||||
SECRET_KEY = "AAAAAAAAAAAAAAAAAAAA"
|
|
||||||
PRESERVE_CONTEXT_ON_EXCEPTION = False
|
|
||||||
TESTING = True
|
|
||||||
DEBUG = True
|
|
||||||
SQLALCHEMY_DATABASE_URI = os.getenv("TESTING_DATABASE_URL") or "sqlite://"
|
|
||||||
MAIL_SERVER = os.getenv("TESTING_MAIL_SERVER")
|
|
||||||
SERVER_NAME = "localhost"
|
|
||||||
UPDATE_CHECK = False
|
|
||||||
REDIS_URL = None
|
|
||||||
CACHE_TYPE = "simple"
|
|
||||||
CACHE_THRESHOLD = 500
|
|
||||||
SAFE_MODE = True
|
|
||||||
|
|
||||||
|
|
||||||
# Actually initialize ServerConfig to allow us to add more attributes on
|
|
||||||
Config = ServerConfig()
|
|
||||||
for k, v in config_ini.items("extra"):
|
|
||||||
setattr(Config, k, v)
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
from enum import Enum
|
|
||||||
|
|
||||||
from flask import current_app
|
|
||||||
|
|
||||||
JS_ENUMS = {}
|
|
||||||
JINJA_ENUMS = {}
|
|
||||||
|
|
||||||
|
|
||||||
class RawEnum(Enum):
|
|
||||||
"""
|
|
||||||
This is a customized enum class which should be used with a mixin.
|
|
||||||
The mixin should define the types of each member.
|
|
||||||
|
|
||||||
For example:
|
|
||||||
|
|
||||||
class Colors(str, RawEnum):
|
|
||||||
RED = "red"
|
|
||||||
GREEN = "green"
|
|
||||||
BLUE = "blue"
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return str(self._value_)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def keys(cls):
|
|
||||||
return list(cls.__members__.keys())
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def values(cls):
|
|
||||||
return list(cls.__members__.values())
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def test(cls, value):
|
|
||||||
try:
|
|
||||||
return bool(cls(value))
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def JSEnum(cls):
|
|
||||||
"""
|
|
||||||
This is a decorator used to gather all Enums which should be shared with
|
|
||||||
the CTFd front end. The JS_Enums dictionary can be taken be a script and
|
|
||||||
compiled into a JavaScript file for use by frontend assets. JS_Enums
|
|
||||||
should not be passed directly into Jinja. A JinjaEnum is better for that.
|
|
||||||
"""
|
|
||||||
if cls.__name__ not in JS_ENUMS:
|
|
||||||
JS_ENUMS[cls.__name__] = dict(cls.__members__)
|
|
||||||
else:
|
|
||||||
raise KeyError("{} was already defined as a JSEnum".format(cls.__name__))
|
|
||||||
return cls
|
|
||||||
|
|
||||||
|
|
||||||
def JinjaEnum(cls):
|
|
||||||
"""
|
|
||||||
This is a decorator used to inject the decorated Enum into Jinja globals
|
|
||||||
which allows you to access it from the front end. If you need to access
|
|
||||||
an Enum from JS, a better tool to use is the JSEnum decorator.
|
|
||||||
"""
|
|
||||||
if cls.__name__ not in current_app.jinja_env.globals:
|
|
||||||
current_app.jinja_env.globals[cls.__name__] = cls
|
|
||||||
JINJA_ENUMS[cls.__name__] = cls
|
|
||||||
else:
|
|
||||||
raise KeyError("{} was already defined as a JinjaEnum".format(cls.__name__))
|
|
||||||
return cls
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
from flask import current_app, url_for
|
|
||||||
|
|
||||||
from CTFd.utils import get_asset_json
|
|
||||||
from CTFd.utils.config import ctf_theme
|
|
||||||
from CTFd.utils.helpers import markup
|
|
||||||
|
|
||||||
|
|
||||||
class _AssetsWrapper:
|
|
||||||
def manifest(self):
|
|
||||||
theme = ctf_theme()
|
|
||||||
manifest = os.path.join(
|
|
||||||
current_app.root_path, "themes", theme, "static", "manifest.json"
|
|
||||||
)
|
|
||||||
return get_asset_json(path=manifest)
|
|
||||||
|
|
||||||
def js(self, asset_key):
|
|
||||||
asset = self.manifest()[asset_key]
|
|
||||||
entry = asset["file"]
|
|
||||||
imports = asset.get("imports", [])
|
|
||||||
html = ""
|
|
||||||
for i in imports:
|
|
||||||
# TODO: Needs a better recursive solution
|
|
||||||
i = self.manifest()[i]["file"]
|
|
||||||
url = url_for("views.themes_beta", path=i)
|
|
||||||
html += f'<script defer type="module" src="{url}"></script>'
|
|
||||||
url = url_for("views.themes_beta", path=entry)
|
|
||||||
html += f'<script defer type="module" src="{url}"></script>'
|
|
||||||
return markup(html)
|
|
||||||
|
|
||||||
def css(self, asset_key):
|
|
||||||
asset = self.manifest()[asset_key]
|
|
||||||
entry = asset["file"]
|
|
||||||
url = url_for("views.themes_beta", path=entry)
|
|
||||||
return markup(f'<link rel="stylesheet" href="{url}">')
|
|
||||||
|
|
||||||
def file(self, asset_key):
|
|
||||||
asset = self.manifest()[asset_key]
|
|
||||||
entry = asset["file"]
|
|
||||||
return url_for("views.themes_beta", path=entry)
|
|
||||||
|
|
||||||
|
|
||||||
Assets = _AssetsWrapper()
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
from flask import url_for
|
|
||||||
|
|
||||||
from CTFd.constants import JinjaEnum, RawEnum
|
|
||||||
from CTFd.utils import get_config
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigTypes(str, RawEnum):
|
|
||||||
CHALLENGE_VISIBILITY = "challenge_visibility"
|
|
||||||
SCORE_VISIBILITY = "score_visibility"
|
|
||||||
ACCOUNT_VISIBILITY = "account_visibility"
|
|
||||||
REGISTRATION_VISIBILITY = "registration_visibility"
|
|
||||||
|
|
||||||
|
|
||||||
@JinjaEnum
|
|
||||||
class UserModeTypes(str, RawEnum):
|
|
||||||
USERS = "users"
|
|
||||||
TEAMS = "teams"
|
|
||||||
|
|
||||||
|
|
||||||
@JinjaEnum
|
|
||||||
class ChallengeVisibilityTypes(str, RawEnum):
|
|
||||||
PUBLIC = "public"
|
|
||||||
PRIVATE = "private"
|
|
||||||
ADMINS = "admins"
|
|
||||||
|
|
||||||
|
|
||||||
@JinjaEnum
|
|
||||||
class ScoreVisibilityTypes(str, RawEnum):
|
|
||||||
PUBLIC = "public"
|
|
||||||
PRIVATE = "private"
|
|
||||||
HIDDEN = "hidden"
|
|
||||||
ADMINS = "admins"
|
|
||||||
|
|
||||||
|
|
||||||
@JinjaEnum
|
|
||||||
class AccountVisibilityTypes(str, RawEnum):
|
|
||||||
PUBLIC = "public"
|
|
||||||
PRIVATE = "private"
|
|
||||||
ADMINS = "admins"
|
|
||||||
|
|
||||||
|
|
||||||
@JinjaEnum
|
|
||||||
class RegistrationVisibilityTypes(str, RawEnum):
|
|
||||||
PUBLIC = "public"
|
|
||||||
PRIVATE = "private"
|
|
||||||
|
|
||||||
|
|
||||||
class _ConfigsWrapper:
|
|
||||||
def __getattr__(self, attr):
|
|
||||||
return get_config(attr)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ctf_name(self):
|
|
||||||
return get_config("ctf_name", default="CTFd")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ctf_small_icon(self):
|
|
||||||
icon = get_config("ctf_small_icon")
|
|
||||||
if icon:
|
|
||||||
return url_for("views.files", path=icon)
|
|
||||||
return url_for("views.themes", path="img/favicon.ico")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def theme_header(self):
|
|
||||||
from CTFd.utils.helpers import markup
|
|
||||||
|
|
||||||
return markup(get_config("theme_header", default=""))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def theme_footer(self):
|
|
||||||
from CTFd.utils.helpers import markup
|
|
||||||
|
|
||||||
return markup(get_config("theme_footer", default=""))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def theme_settings(self):
|
|
||||||
try:
|
|
||||||
return json.loads(get_config("theme_settings", default="null"))
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return {"error": "invalid theme_settings"}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tos_or_privacy(self):
|
|
||||||
tos = bool(get_config("tos_url") or get_config("tos_text"))
|
|
||||||
privacy = bool(get_config("privacy_url") or get_config("privacy_text"))
|
|
||||||
return tos or privacy
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tos_link(self):
|
|
||||||
return get_config("tos_url", default=url_for("views.tos"))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def privacy_link(self):
|
|
||||||
return get_config("privacy_url", default=url_for("views.privacy"))
|
|
||||||
|
|
||||||
|
|
||||||
Configs = _ConfigsWrapper()
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
from CTFd.constants import RawEnum
|
|
||||||
|
|
||||||
|
|
||||||
class Languages(str, RawEnum):
|
|
||||||
ENGLISH = "en"
|
|
||||||
GERMAN = "de"
|
|
||||||
POLISH = "pl"
|
|
||||||
|
|
||||||
|
|
||||||
LANGUAGE_NAMES = {
|
|
||||||
"en": "English",
|
|
||||||
"de": "Deutsch",
|
|
||||||
"pl": "Polski",
|
|
||||||
}
|
|
||||||
|
|
||||||
SELECT_LANGUAGE_LIST = [("", "")] + [
|
|
||||||
(str(lang), LANGUAGE_NAMES.get(str(lang))) for lang in Languages
|
|
||||||
]
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
from flask import current_app
|
|
||||||
|
|
||||||
from CTFd.plugins import get_admin_plugin_menu_bar, get_user_page_menu_bar
|
|
||||||
from CTFd.utils.helpers import markup
|
|
||||||
from CTFd.utils.plugins import get_registered_scripts, get_registered_stylesheets
|
|
||||||
|
|
||||||
|
|
||||||
class _PluginWrapper:
|
|
||||||
@property
|
|
||||||
def scripts(self):
|
|
||||||
application_root = current_app.config.get("APPLICATION_ROOT")
|
|
||||||
subdir = application_root != "/"
|
|
||||||
scripts = []
|
|
||||||
for script in get_registered_scripts():
|
|
||||||
if script.startswith("http"):
|
|
||||||
scripts.append(f'<script defer src="{script}"></script>')
|
|
||||||
elif subdir:
|
|
||||||
scripts.append(
|
|
||||||
f'<script defer src="{application_root}/{script}"></script>'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
scripts.append(f'<script defer src="{script}"></script>')
|
|
||||||
return markup("\n".join(scripts))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def styles(self):
|
|
||||||
application_root = current_app.config.get("APPLICATION_ROOT")
|
|
||||||
subdir = application_root != "/"
|
|
||||||
_styles = []
|
|
||||||
for stylesheet in get_registered_stylesheets():
|
|
||||||
if stylesheet.startswith("http"):
|
|
||||||
_styles.append(
|
|
||||||
f'<link rel="stylesheet" type="text/css" href="{stylesheet}">'
|
|
||||||
)
|
|
||||||
elif subdir:
|
|
||||||
_styles.append(
|
|
||||||
f'<link rel="stylesheet" type="text/css" href="{application_root}/{stylesheet}">'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
_styles.append(
|
|
||||||
f'<link rel="stylesheet" type="text/css" href="{stylesheet}">'
|
|
||||||
)
|
|
||||||
return markup("\n".join(_styles))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def user_menu_pages(self):
|
|
||||||
return get_user_page_menu_bar()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def admin_menu_pages(self):
|
|
||||||
return get_admin_plugin_menu_bar()
|
|
||||||
|
|
||||||
|
|
||||||
Plugins = _PluginWrapper()
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
from flask import session
|
|
||||||
|
|
||||||
|
|
||||||
class _SessionWrapper:
|
|
||||||
@property
|
|
||||||
def id(self):
|
|
||||||
return session.get("id", 0)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def nonce(self):
|
|
||||||
return session.get("nonce")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def hash(self):
|
|
||||||
return session.get("hash")
|
|
||||||
|
|
||||||
|
|
||||||
Session = _SessionWrapper()
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
from CTFd.constants import JinjaEnum, RawEnum
|
|
||||||
|
|
||||||
|
|
||||||
@JinjaEnum
|
|
||||||
class CacheKeys(str, RawEnum):
|
|
||||||
PUBLIC_SCOREBOARD_TABLE = "public_scoreboard_table"
|
|
||||||
|
|
||||||
|
|
||||||
# Placeholder object. Not used, just imported to force initialization of any Enums here
|
|
||||||
class _StaticsWrapper:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
Static = _StaticsWrapper()
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
from collections import namedtuple
|
|
||||||
|
|
||||||
TeamAttrs = namedtuple(
|
|
||||||
"TeamAttrs",
|
|
||||||
[
|
|
||||||
"id",
|
|
||||||
"oauth_id",
|
|
||||||
"name",
|
|
||||||
"email",
|
|
||||||
"secret",
|
|
||||||
"website",
|
|
||||||
"affiliation",
|
|
||||||
"country",
|
|
||||||
"bracket",
|
|
||||||
"hidden",
|
|
||||||
"banned",
|
|
||||||
"captain_id",
|
|
||||||
"created",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class _TeamAttrsWrapper:
|
|
||||||
def __getattr__(self, attr):
|
|
||||||
from CTFd.utils.user import get_current_team_attrs
|
|
||||||
|
|
||||||
attrs = get_current_team_attrs()
|
|
||||||
return getattr(attrs, attr, None)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def place(self):
|
|
||||||
from CTFd.utils.user import get_team_place
|
|
||||||
|
|
||||||
return get_team_place(team_id=self.id)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def score(self):
|
|
||||||
from CTFd.utils.user import get_team_score
|
|
||||||
|
|
||||||
return get_team_score(team_id=self.id)
|
|
||||||
|
|
||||||
|
|
||||||
Team = _TeamAttrsWrapper()
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ADMIN_THEME = "admin"
|
|
||||||
DEFAULT_THEME = "core"
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
from collections import namedtuple
|
|
||||||
|
|
||||||
UserAttrs = namedtuple(
|
|
||||||
"UserAttrs",
|
|
||||||
[
|
|
||||||
"id",
|
|
||||||
"oauth_id",
|
|
||||||
"name",
|
|
||||||
"email",
|
|
||||||
"type",
|
|
||||||
"secret",
|
|
||||||
"website",
|
|
||||||
"affiliation",
|
|
||||||
"country",
|
|
||||||
"bracket",
|
|
||||||
"hidden",
|
|
||||||
"banned",
|
|
||||||
"verified",
|
|
||||||
"language",
|
|
||||||
"team_id",
|
|
||||||
"created",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class _UserAttrsWrapper:
|
|
||||||
def __getattr__(self, attr):
|
|
||||||
from CTFd.utils.user import get_current_user_attrs
|
|
||||||
|
|
||||||
attrs = get_current_user_attrs()
|
|
||||||
return getattr(attrs, attr, None)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def place(self):
|
|
||||||
from CTFd.utils.user import get_user_place
|
|
||||||
|
|
||||||
return get_user_place(user_id=self.id)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def score(self):
|
|
||||||
from CTFd.utils.user import get_user_score
|
|
||||||
|
|
||||||
return get_user_score(user_id=self.id)
|
|
||||||
|
|
||||||
|
|
||||||
User = _UserAttrsWrapper()
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import jinja2.exceptions
|
|
||||||
from flask import render_template
|
|
||||||
from werkzeug.exceptions import InternalServerError
|
|
||||||
|
|
||||||
|
|
||||||
def render_error(error):
|
|
||||||
if (
|
|
||||||
isinstance(error, InternalServerError)
|
|
||||||
and error.description == InternalServerError.description
|
|
||||||
):
|
|
||||||
error.description = "An Internal Server Error has occurred"
|
|
||||||
try:
|
|
||||||
return (
|
|
||||||
render_template(
|
|
||||||
"errors/{}.html".format(error.code), error=error.description,
|
|
||||||
),
|
|
||||||
error.code,
|
|
||||||
)
|
|
||||||
except jinja2.exceptions.TemplateNotFound:
|
|
||||||
return error.get_response()
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
from flask import Blueprint, Response, current_app, stream_with_context
|
|
||||||
|
|
||||||
from CTFd.utils import get_app_config
|
|
||||||
from CTFd.utils.decorators import authed_only, ratelimit
|
|
||||||
|
|
||||||
events = Blueprint("events", __name__)
|
|
||||||
|
|
||||||
|
|
||||||
@events.route("/events")
|
|
||||||
@authed_only
|
|
||||||
@ratelimit(method="GET", limit=150, interval=60)
|
|
||||||
def subscribe():
|
|
||||||
@stream_with_context
|
|
||||||
def gen():
|
|
||||||
for event in current_app.events_manager.subscribe():
|
|
||||||
yield str(event)
|
|
||||||
|
|
||||||
enabled = get_app_config("SERVER_SENT_EVENTS")
|
|
||||||
if enabled is False:
|
|
||||||
return ("", 204)
|
|
||||||
|
|
||||||
return Response(gen(), mimetype="text/event-stream")
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
class UserNotFoundException(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class UserTokenExpiredException(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class TeamTokenExpiredException(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class TeamTokenInvalidException(Exception):
|
|
||||||
pass
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
from wtforms import Form
|
|
||||||
from wtforms.csrf.core import CSRF
|
|
||||||
|
|
||||||
|
|
||||||
class CTFdCSRF(CSRF):
|
|
||||||
def generate_csrf_token(self, csrf_token_field):
|
|
||||||
from flask import session
|
|
||||||
|
|
||||||
return session.get("nonce")
|
|
||||||
|
|
||||||
|
|
||||||
class BaseForm(Form):
|
|
||||||
class Meta:
|
|
||||||
csrf = True
|
|
||||||
csrf_class = CTFdCSRF
|
|
||||||
csrf_field_name = "nonce"
|
|
||||||
|
|
||||||
|
|
||||||
class _FormsWrapper:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
Forms = _FormsWrapper()
|
|
||||||
|
|
||||||
from CTFd.forms import auth # noqa: I001 isort:skip
|
|
||||||
from CTFd.forms import self # noqa: I001 isort:skip
|
|
||||||
from CTFd.forms import teams # noqa: I001 isort:skip
|
|
||||||
from CTFd.forms import setup # noqa: I001 isort:skip
|
|
||||||
from CTFd.forms import submissions # noqa: I001 isort:skip
|
|
||||||
from CTFd.forms import users # noqa: I001 isort:skip
|
|
||||||
from CTFd.forms import challenges # noqa: I001 isort:skip
|
|
||||||
from CTFd.forms import notifications # noqa: I001 isort:skip
|
|
||||||
from CTFd.forms import config # noqa: I001 isort:skip
|
|
||||||
from CTFd.forms import pages # noqa: I001 isort:skip
|
|
||||||
from CTFd.forms import awards # noqa: I001 isort:skip
|
|
||||||
from CTFd.forms import email # noqa: I001 isort:skip
|
|
||||||
|
|
||||||
Forms.auth = auth
|
|
||||||
Forms.self = self
|
|
||||||
Forms.teams = teams
|
|
||||||
Forms.setup = setup
|
|
||||||
Forms.submissions = submissions
|
|
||||||
Forms.users = users
|
|
||||||
Forms.challenges = challenges
|
|
||||||
Forms.notifications = notifications
|
|
||||||
Forms.config = config
|
|
||||||
Forms.pages = pages
|
|
||||||
Forms.awards = awards
|
|
||||||
Forms.email = email
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
from flask_babel import lazy_gettext as _l
|
|
||||||
from wtforms import PasswordField, StringField
|
|
||||||
from wtforms.fields.html5 import EmailField
|
|
||||||
from wtforms.validators import InputRequired
|
|
||||||
|
|
||||||
from CTFd.forms import BaseForm
|
|
||||||
from CTFd.forms.fields import SubmitField
|
|
||||||
from CTFd.forms.users import (
|
|
||||||
attach_custom_user_fields,
|
|
||||||
attach_registration_code_field,
|
|
||||||
build_custom_user_fields,
|
|
||||||
build_registration_code_field,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def RegistrationForm(*args, **kwargs):
|
|
||||||
class _RegistrationForm(BaseForm):
|
|
||||||
name = StringField(
|
|
||||||
_l("User Name"), validators=[InputRequired()], render_kw={"autofocus": True}
|
|
||||||
)
|
|
||||||
email = EmailField(_l("Email"), validators=[InputRequired()])
|
|
||||||
password = PasswordField(_l("Password"), validators=[InputRequired()])
|
|
||||||
submit = SubmitField(_l("Submit"))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def extra(self):
|
|
||||||
return build_custom_user_fields(
|
|
||||||
self, include_entries=False, blacklisted_items=()
|
|
||||||
) + build_registration_code_field(self)
|
|
||||||
|
|
||||||
attach_custom_user_fields(_RegistrationForm)
|
|
||||||
attach_registration_code_field(_RegistrationForm)
|
|
||||||
|
|
||||||
return _RegistrationForm(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class LoginForm(BaseForm):
|
|
||||||
name = StringField(
|
|
||||||
_l("User Name or Email"),
|
|
||||||
validators=[InputRequired()],
|
|
||||||
render_kw={"autofocus": True},
|
|
||||||
)
|
|
||||||
password = PasswordField(_l("Password"), validators=[InputRequired()])
|
|
||||||
submit = SubmitField(_l("Submit"))
|
|
||||||
|
|
||||||
|
|
||||||
class ConfirmForm(BaseForm):
|
|
||||||
submit = SubmitField(_l("Resend Confirmation Email"))
|
|
||||||
|
|
||||||
|
|
||||||
class ResetPasswordRequestForm(BaseForm):
|
|
||||||
email = EmailField(
|
|
||||||
_l("Email"), validators=[InputRequired()], render_kw={"autofocus": True}
|
|
||||||
)
|
|
||||||
submit = SubmitField(_l("Submit"))
|
|
||||||
|
|
||||||
|
|
||||||
class ResetPasswordForm(BaseForm):
|
|
||||||
password = PasswordField(
|
|
||||||
_l("Password"), validators=[InputRequired()], render_kw={"autofocus": True}
|
|
||||||
)
|
|
||||||
submit = SubmitField(_l("Submit"))
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
from wtforms import RadioField, StringField, TextAreaField
|
|
||||||
from wtforms.fields.html5 import IntegerField
|
|
||||||
|
|
||||||
from CTFd.forms import BaseForm
|
|
||||||
from CTFd.forms.fields import SubmitField
|
|
||||||
|
|
||||||
|
|
||||||
class AwardCreationForm(BaseForm):
|
|
||||||
name = StringField("Name")
|
|
||||||
value = IntegerField("Value")
|
|
||||||
category = StringField("Category")
|
|
||||||
description = TextAreaField("Description")
|
|
||||||
submit = SubmitField("Create")
|
|
||||||
icon = RadioField(
|
|
||||||
"Icon",
|
|
||||||
choices=[
|
|
||||||
("", "None"),
|
|
||||||
("shield", "Shield"),
|
|
||||||
("bug", "Bug"),
|
|
||||||
("crown", "Crown"),
|
|
||||||
("crosshairs", "Crosshairs"),
|
|
||||||
("ban", "Ban"),
|
|
||||||
("lightning", "Lightning"),
|
|
||||||
("skull", "Skull"),
|
|
||||||
("brain", "Brain"),
|
|
||||||
("code", "Code"),
|
|
||||||
("cowboy", "Cowboy"),
|
|
||||||
("angry", "Angry"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
from wtforms import MultipleFileField, SelectField, StringField
|
|
||||||
from wtforms.validators import InputRequired
|
|
||||||
|
|
||||||
from CTFd.forms import BaseForm
|
|
||||||
from CTFd.forms.fields import SubmitField
|
|
||||||
|
|
||||||
|
|
||||||
class ChallengeSearchForm(BaseForm):
|
|
||||||
field = SelectField(
|
|
||||||
"Search Field",
|
|
||||||
choices=[
|
|
||||||
("name", "Name"),
|
|
||||||
("id", "ID"),
|
|
||||||
("category", "Category"),
|
|
||||||
("type", "Type"),
|
|
||||||
],
|
|
||||||
default="name",
|
|
||||||
validators=[InputRequired()],
|
|
||||||
)
|
|
||||||
q = StringField("Parameter", validators=[InputRequired()])
|
|
||||||
submit = SubmitField("Search")
|
|
||||||
|
|
||||||
|
|
||||||
class ChallengeFilesUploadForm(BaseForm):
|
|
||||||
file = MultipleFileField(
|
|
||||||
"Upload Files",
|
|
||||||
description="Attach multiple files using Control+Click or Cmd+Click.",
|
|
||||||
validators=[InputRequired()],
|
|
||||||
)
|
|
||||||
submit = SubmitField("Upload")
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
from wtforms import BooleanField, FileField, SelectField, StringField, TextAreaField
|
|
||||||
from wtforms.fields.html5 import IntegerField, URLField
|
|
||||||
from wtforms.widgets.html5 import NumberInput
|
|
||||||
|
|
||||||
from CTFd.forms import BaseForm
|
|
||||||
from CTFd.forms.fields import SubmitField
|
|
||||||
from CTFd.utils.csv import get_dumpable_tables
|
|
||||||
|
|
||||||
|
|
||||||
class ResetInstanceForm(BaseForm):
|
|
||||||
accounts = BooleanField(
|
|
||||||
"Accounts",
|
|
||||||
description="Deletes all user and team accounts and their associated information",
|
|
||||||
)
|
|
||||||
submissions = BooleanField(
|
|
||||||
"Submissions",
|
|
||||||
description="Deletes all records that accounts gained points or took an action",
|
|
||||||
)
|
|
||||||
challenges = BooleanField(
|
|
||||||
"Challenges", description="Deletes all challenges and associated data"
|
|
||||||
)
|
|
||||||
pages = BooleanField(
|
|
||||||
"Pages", description="Deletes all pages and their associated files"
|
|
||||||
)
|
|
||||||
notifications = BooleanField(
|
|
||||||
"Notifications", description="Deletes all notifications"
|
|
||||||
)
|
|
||||||
submit = SubmitField("Reset CTF")
|
|
||||||
|
|
||||||
|
|
||||||
class AccountSettingsForm(BaseForm):
|
|
||||||
domain_whitelist = StringField(
|
|
||||||
"Account Email Whitelist",
|
|
||||||
description="Comma-seperated email domains which users can register under (e.g. ctfd.io, gmail.com, yahoo.com)",
|
|
||||||
)
|
|
||||||
team_creation = SelectField(
|
|
||||||
"Team Creation",
|
|
||||||
description="Control whether users can create their own teams (Teams mode only)",
|
|
||||||
choices=[("true", "Enabled"), ("false", "Disabled")],
|
|
||||||
default="true",
|
|
||||||
)
|
|
||||||
team_size = IntegerField(
|
|
||||||
widget=NumberInput(min=0),
|
|
||||||
description="Amount of users per team (Teams mode only)",
|
|
||||||
)
|
|
||||||
num_teams = IntegerField(
|
|
||||||
"Total Number of Teams",
|
|
||||||
widget=NumberInput(min=0),
|
|
||||||
description="Max number of teams (Teams mode only)",
|
|
||||||
)
|
|
||||||
verify_emails = SelectField(
|
|
||||||
"Verify Emails",
|
|
||||||
description="Control whether users must confirm their email addresses before playing",
|
|
||||||
choices=[("true", "Enabled"), ("false", "Disabled")],
|
|
||||||
default="false",
|
|
||||||
)
|
|
||||||
team_disbanding = SelectField(
|
|
||||||
"Team Disbanding",
|
|
||||||
description="Control whether team captains are allowed to disband their own teams",
|
|
||||||
choices=[
|
|
||||||
("inactive_only", "Enabled for Inactive Teams"),
|
|
||||||
("disabled", "Disabled"),
|
|
||||||
],
|
|
||||||
default="inactive_only",
|
|
||||||
)
|
|
||||||
name_changes = SelectField(
|
|
||||||
"Name Changes",
|
|
||||||
description="Control whether users and teams can change their names",
|
|
||||||
choices=[("true", "Enabled"), ("false", "Disabled")],
|
|
||||||
default="true",
|
|
||||||
)
|
|
||||||
incorrect_submissions_per_min = IntegerField(
|
|
||||||
"Incorrect Submissions per Minute",
|
|
||||||
widget=NumberInput(min=1),
|
|
||||||
description="Amount of submissions allowed per minute for flag bruteforce protection (default: 10)",
|
|
||||||
)
|
|
||||||
|
|
||||||
submit = SubmitField("Update")
|
|
||||||
|
|
||||||
|
|
||||||
class ExportCSVForm(BaseForm):
|
|
||||||
table = SelectField("Database Table", choices=get_dumpable_tables())
|
|
||||||
submit = SubmitField("Download CSV")
|
|
||||||
|
|
||||||
|
|
||||||
class ImportCSVForm(BaseForm):
|
|
||||||
csv_type = SelectField(
|
|
||||||
"CSV Type",
|
|
||||||
choices=[("users", "Users"), ("teams", "Teams"), ("challenges", "Challenges")],
|
|
||||||
description="Type of CSV data",
|
|
||||||
)
|
|
||||||
csv_file = FileField("CSV File", description="CSV file contents")
|
|
||||||
|
|
||||||
|
|
||||||
class LegalSettingsForm(BaseForm):
|
|
||||||
tos_url = URLField(
|
|
||||||
"Terms of Service URL",
|
|
||||||
description="External URL to a Terms of Service document hosted elsewhere",
|
|
||||||
)
|
|
||||||
tos_text = TextAreaField(
|
|
||||||
"Terms of Service", description="Text shown on the Terms of Service page",
|
|
||||||
)
|
|
||||||
privacy_url = URLField(
|
|
||||||
"Privacy Policy URL",
|
|
||||||
description="External URL to a Privacy Policy document hosted elsewhere",
|
|
||||||
)
|
|
||||||
privacy_text = TextAreaField(
|
|
||||||
"Privacy Policy", description="Text shown on the Privacy Policy page",
|
|
||||||
)
|
|
||||||
submit = SubmitField("Update")
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
from wtforms import TextAreaField
|
|
||||||
from wtforms.validators import InputRequired
|
|
||||||
|
|
||||||
from CTFd.forms import BaseForm
|
|
||||||
from CTFd.forms.fields import SubmitField
|
|
||||||
|
|
||||||
|
|
||||||
class SendEmailForm(BaseForm):
|
|
||||||
text = TextAreaField("Message", validators=[InputRequired()])
|
|
||||||
submit = SubmitField("Send")
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
from wtforms import SubmitField as _SubmitField
|
|
||||||
|
|
||||||
|
|
||||||
class SubmitField(_SubmitField):
|
|
||||||
"""
|
|
||||||
This custom SubmitField exists because wtforms is dumb.
|
|
||||||
|
|
||||||
See https://github.com/wtforms/wtforms/issues/205, https://github.com/wtforms/wtforms/issues/36
|
|
||||||
The .submit() handler in JS will break if the form has an input with the name or id of "submit" so submit fields need to be changed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
name = kwargs.pop("name", "_submit")
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
if self.name == "submit" or name:
|
|
||||||
self.id = name
|
|
||||||
self.name = name
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
from wtforms import BooleanField, RadioField, StringField, TextAreaField
|
|
||||||
from wtforms.validators import InputRequired
|
|
||||||
|
|
||||||
from CTFd.forms import BaseForm
|
|
||||||
from CTFd.forms.fields import SubmitField
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationForm(BaseForm):
|
|
||||||
title = StringField("Title", description="Notification title")
|
|
||||||
content = TextAreaField(
|
|
||||||
"Content",
|
|
||||||
description="Notification contents. Can consist of HTML and/or Markdown.",
|
|
||||||
)
|
|
||||||
type = RadioField(
|
|
||||||
"Notification Type",
|
|
||||||
choices=[("toast", "Toast"), ("alert", "Alert"), ("background", "Background")],
|
|
||||||
default="toast",
|
|
||||||
description="What type of notification users receive",
|
|
||||||
validators=[InputRequired()],
|
|
||||||
)
|
|
||||||
sound = BooleanField(
|
|
||||||
"Play Sound",
|
|
||||||
default=True,
|
|
||||||
description="Play sound for users when they receive the notification",
|
|
||||||
)
|
|
||||||
submit = SubmitField("Submit")
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
from wtforms import (
|
|
||||||
BooleanField,
|
|
||||||
HiddenField,
|
|
||||||
MultipleFileField,
|
|
||||||
SelectField,
|
|
||||||
StringField,
|
|
||||||
TextAreaField,
|
|
||||||
)
|
|
||||||
from wtforms.validators import InputRequired
|
|
||||||
|
|
||||||
from CTFd.forms import BaseForm
|
|
||||||
|
|
||||||
|
|
||||||
class PageEditForm(BaseForm):
|
|
||||||
title = StringField(
|
|
||||||
"Title", description="This is the title shown on the navigation bar"
|
|
||||||
)
|
|
||||||
route = StringField(
|
|
||||||
"Route",
|
|
||||||
description="This is the URL route that your page will be at (e.g. /page). You can also enter links to link to that page.",
|
|
||||||
)
|
|
||||||
draft = BooleanField("Draft")
|
|
||||||
hidden = BooleanField("Hidden")
|
|
||||||
auth_required = BooleanField("Authentication Required")
|
|
||||||
content = TextAreaField("Content")
|
|
||||||
format = SelectField(
|
|
||||||
"Format",
|
|
||||||
choices=[("markdown", "Markdown"), ("html", "HTML")],
|
|
||||||
default="markdown",
|
|
||||||
validators=[InputRequired()],
|
|
||||||
description="The markup format used to render the page",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PageFilesUploadForm(BaseForm):
|
|
||||||
file = MultipleFileField(
|
|
||||||
"Upload Files",
|
|
||||||
description="Attach multiple files using Control+Click or Cmd+Click.",
|
|
||||||
validators=[InputRequired()],
|
|
||||||
)
|
|
||||||
type = HiddenField("Page Type", default="page", validators=[InputRequired()])
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
from flask import session
|
|
||||||
from flask_babel import lazy_gettext as _l
|
|
||||||
from wtforms import PasswordField, SelectField, StringField
|
|
||||||
from wtforms.fields.html5 import DateField, URLField
|
|
||||||
|
|
||||||
from CTFd.constants.languages import SELECT_LANGUAGE_LIST
|
|
||||||
from CTFd.forms import BaseForm
|
|
||||||
from CTFd.forms.fields import SubmitField
|
|
||||||
from CTFd.forms.users import attach_custom_user_fields, build_custom_user_fields
|
|
||||||
from CTFd.utils.countries import SELECT_COUNTRIES_LIST
|
|
||||||
from CTFd.utils.user import get_current_user
|
|
||||||
|
|
||||||
|
|
||||||
def SettingsForm(*args, **kwargs):
|
|
||||||
class _SettingsForm(BaseForm):
|
|
||||||
name = StringField(_l("User Name"))
|
|
||||||
email = StringField(_l("Email"))
|
|
||||||
language = SelectField(_l("Language"), choices=SELECT_LANGUAGE_LIST)
|
|
||||||
password = PasswordField(_l("Password"))
|
|
||||||
confirm = PasswordField(_l("Current Password"))
|
|
||||||
affiliation = StringField(_l("Affiliation"))
|
|
||||||
website = URLField(_l("Website"))
|
|
||||||
country = SelectField(_l("Country"), choices=SELECT_COUNTRIES_LIST)
|
|
||||||
submit = SubmitField(_l("Submit"))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def extra(self):
|
|
||||||
fields_kwargs = _SettingsForm.get_field_kwargs()
|
|
||||||
return build_custom_user_fields(
|
|
||||||
self,
|
|
||||||
include_entries=True,
|
|
||||||
fields_kwargs=fields_kwargs,
|
|
||||||
field_entries_kwargs={"user_id": session["id"]},
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_field_kwargs():
|
|
||||||
user = get_current_user()
|
|
||||||
field_kwargs = {"editable": True}
|
|
||||||
if user.filled_all_required_fields is False:
|
|
||||||
# Show all fields
|
|
||||||
field_kwargs = {}
|
|
||||||
return field_kwargs
|
|
||||||
|
|
||||||
field_kwargs = _SettingsForm.get_field_kwargs()
|
|
||||||
attach_custom_user_fields(_SettingsForm, **field_kwargs)
|
|
||||||
|
|
||||||
return _SettingsForm(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class TokensForm(BaseForm):
|
|
||||||
expiration = DateField(_l("Expiration"))
|
|
||||||
submit = SubmitField(_l("Generate"))
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
from flask_babel import lazy_gettext as _l
|
|
||||||
from wtforms import (
|
|
||||||
FileField,
|
|
||||||
HiddenField,
|
|
||||||
PasswordField,
|
|
||||||
RadioField,
|
|
||||||
SelectField,
|
|
||||||
StringField,
|
|
||||||
TextAreaField,
|
|
||||||
)
|
|
||||||
from wtforms.fields.html5 import EmailField
|
|
||||||
from wtforms.validators import InputRequired
|
|
||||||
|
|
||||||
from CTFd.constants.themes import DEFAULT_THEME
|
|
||||||
from CTFd.forms import BaseForm
|
|
||||||
from CTFd.forms.fields import SubmitField
|
|
||||||
from CTFd.utils.config import get_themes
|
|
||||||
|
|
||||||
|
|
||||||
class SetupForm(BaseForm):
|
|
||||||
ctf_name = StringField(
|
|
||||||
_l("Event Name"), description=_l("The name of your CTF event/workshop")
|
|
||||||
)
|
|
||||||
ctf_description = TextAreaField(
|
|
||||||
_l("Event Description"), description=_l("Description for the CTF")
|
|
||||||
)
|
|
||||||
user_mode = RadioField(
|
|
||||||
_l("User Mode"),
|
|
||||||
choices=[("teams", _l("Team Mode")), ("users", _l("User Mode"))],
|
|
||||||
default="teams",
|
|
||||||
description=_l(
|
|
||||||
"Controls whether users join together in teams to play (Team Mode) or play as themselves (User Mode)"
|
|
||||||
),
|
|
||||||
validators=[InputRequired()],
|
|
||||||
)
|
|
||||||
|
|
||||||
name = StringField(
|
|
||||||
_l("Admin Username"),
|
|
||||||
description=_l("Your username for the administration account"),
|
|
||||||
validators=[InputRequired()],
|
|
||||||
)
|
|
||||||
email = EmailField(
|
|
||||||
_l("Admin Email"),
|
|
||||||
description=_l("Your email address for the administration account"),
|
|
||||||
validators=[InputRequired()],
|
|
||||||
)
|
|
||||||
password = PasswordField(
|
|
||||||
_l("Admin Password"),
|
|
||||||
description=_l("Your password for the administration account"),
|
|
||||||
validators=[InputRequired()],
|
|
||||||
)
|
|
||||||
|
|
||||||
ctf_logo = FileField(
|
|
||||||
_l("Logo"),
|
|
||||||
description=_l(
|
|
||||||
"Logo to use for the website instead of a CTF name. Used as the home page button. Optional."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
ctf_banner = FileField(
|
|
||||||
_l("Banner"), description=_l("Banner to use for the homepage. Optional.")
|
|
||||||
)
|
|
||||||
ctf_small_icon = FileField(
|
|
||||||
_l("Small Icon"),
|
|
||||||
description=_l(
|
|
||||||
"favicon used in user's browsers. Only PNGs accepted. Must be 32x32px. Optional."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
ctf_theme = SelectField(
|
|
||||||
_l("Theme"),
|
|
||||||
description=_l("CTFd Theme to use. Can be changed later."),
|
|
||||||
choices=list(zip(get_themes(), get_themes())),
|
|
||||||
default=DEFAULT_THEME,
|
|
||||||
validators=[InputRequired()],
|
|
||||||
)
|
|
||||||
theme_color = HiddenField(
|
|
||||||
_l("Theme Color"),
|
|
||||||
description=_l(
|
|
||||||
"Color used by theme to control aesthetics. Requires theme support. Optional."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
start = StringField(
|
|
||||||
_l("Start Time"),
|
|
||||||
description=_l("Time when your CTF is scheduled to start. Optional."),
|
|
||||||
)
|
|
||||||
end = StringField(
|
|
||||||
_l("End Time"),
|
|
||||||
description=_l("Time when your CTF is scheduled to end. Optional."),
|
|
||||||
)
|
|
||||||
submit = SubmitField(_l("Finish"))
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
from wtforms import SelectField, StringField
|
|
||||||
from wtforms.validators import InputRequired
|
|
||||||
|
|
||||||
from CTFd.forms import BaseForm
|
|
||||||
from CTFd.forms.fields import SubmitField
|
|
||||||
|
|
||||||
|
|
||||||
class SubmissionSearchForm(BaseForm):
|
|
||||||
field = SelectField(
|
|
||||||
"Search Field",
|
|
||||||
choices=[
|
|
||||||
("provided", "Provided"),
|
|
||||||
("id", "ID"),
|
|
||||||
("account_id", "Account ID"),
|
|
||||||
("challenge_id", "Challenge ID"),
|
|
||||||
("challenge_name", "Challenge Name"),
|
|
||||||
],
|
|
||||||
default="provided",
|
|
||||||
validators=[InputRequired()],
|
|
||||||
)
|
|
||||||
q = StringField("Parameter", validators=[InputRequired()])
|
|
||||||
submit = SubmitField("Search")
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
from flask_babel import lazy_gettext as _l
|
|
||||||
from wtforms import BooleanField, PasswordField, SelectField, StringField
|
|
||||||
from wtforms.fields.html5 import EmailField, URLField
|
|
||||||
from wtforms.validators import InputRequired
|
|
||||||
|
|
||||||
from CTFd.forms import BaseForm
|
|
||||||
from CTFd.forms.fields import SubmitField
|
|
||||||
from CTFd.models import TeamFieldEntries, TeamFields
|
|
||||||
from CTFd.utils.countries import SELECT_COUNTRIES_LIST
|
|
||||||
from CTFd.utils.user import get_current_team
|
|
||||||
|
|
||||||
|
|
||||||
def build_custom_team_fields(
|
|
||||||
form_cls,
|
|
||||||
include_entries=False,
|
|
||||||
fields_kwargs=None,
|
|
||||||
field_entries_kwargs=None,
|
|
||||||
blacklisted_items=("affiliation", "website"),
|
|
||||||
):
|
|
||||||
if fields_kwargs is None:
|
|
||||||
fields_kwargs = {}
|
|
||||||
if field_entries_kwargs is None:
|
|
||||||
field_entries_kwargs = {}
|
|
||||||
|
|
||||||
fields = []
|
|
||||||
new_fields = TeamFields.query.filter_by(**fields_kwargs).all()
|
|
||||||
user_fields = {}
|
|
||||||
|
|
||||||
# Only include preexisting values if asked
|
|
||||||
if include_entries is True:
|
|
||||||
for f in TeamFieldEntries.query.filter_by(**field_entries_kwargs).all():
|
|
||||||
user_fields[f.field_id] = f.value
|
|
||||||
|
|
||||||
for field in new_fields:
|
|
||||||
if field.name.lower() in blacklisted_items:
|
|
||||||
continue
|
|
||||||
|
|
||||||
form_field = getattr(form_cls, f"fields[{field.id}]")
|
|
||||||
|
|
||||||
# Add the field_type to the field so we know how to render it
|
|
||||||
form_field.field_type = field.field_type
|
|
||||||
|
|
||||||
# Only include preexisting values if asked
|
|
||||||
if include_entries is True:
|
|
||||||
initial = user_fields.get(field.id, "")
|
|
||||||
form_field.data = initial
|
|
||||||
if form_field.render_kw:
|
|
||||||
form_field.render_kw["data-initial"] = initial
|
|
||||||
else:
|
|
||||||
form_field.render_kw = {"data-initial": initial}
|
|
||||||
|
|
||||||
fields.append(form_field)
|
|
||||||
return fields
|
|
||||||
|
|
||||||
|
|
||||||
def attach_custom_team_fields(form_cls, **kwargs):
|
|
||||||
new_fields = TeamFields.query.filter_by(**kwargs).all()
|
|
||||||
for field in new_fields:
|
|
||||||
validators = []
|
|
||||||
if field.required:
|
|
||||||
validators.append(InputRequired())
|
|
||||||
|
|
||||||
if field.field_type == "text":
|
|
||||||
input_field = StringField(
|
|
||||||
field.name, description=field.description, validators=validators
|
|
||||||
)
|
|
||||||
elif field.field_type == "boolean":
|
|
||||||
input_field = BooleanField(
|
|
||||||
field.name, description=field.description, validators=validators
|
|
||||||
)
|
|
||||||
|
|
||||||
setattr(form_cls, f"fields[{field.id}]", input_field)
|
|
||||||
|
|
||||||
|
|
||||||
class TeamJoinForm(BaseForm):
|
|
||||||
name = StringField(_l("Team Name"), validators=[InputRequired()])
|
|
||||||
password = PasswordField(_l("Team Password"), validators=[InputRequired()])
|
|
||||||
submit = SubmitField(_l("Join"))
|
|
||||||
|
|
||||||
|
|
||||||
def TeamRegisterForm(*args, **kwargs):
|
|
||||||
class _TeamRegisterForm(BaseForm):
|
|
||||||
name = StringField(_l("Team Name"), validators=[InputRequired()])
|
|
||||||
password = PasswordField(_l("Team Password"), validators=[InputRequired()])
|
|
||||||
submit = SubmitField(_l("Create"))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def extra(self):
|
|
||||||
return build_custom_team_fields(
|
|
||||||
self, include_entries=False, blacklisted_items=()
|
|
||||||
)
|
|
||||||
|
|
||||||
attach_custom_team_fields(_TeamRegisterForm)
|
|
||||||
return _TeamRegisterForm(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def TeamSettingsForm(*args, **kwargs):
|
|
||||||
class _TeamSettingsForm(BaseForm):
|
|
||||||
name = StringField(
|
|
||||||
_l("Team Name"),
|
|
||||||
description=_l("Your team's public name shown to other competitors"),
|
|
||||||
)
|
|
||||||
password = PasswordField(
|
|
||||||
_l("New Team Password"), description=_l("Set a new team join password")
|
|
||||||
)
|
|
||||||
confirm = PasswordField(
|
|
||||||
_l("Confirm Current Team Password"),
|
|
||||||
description=_l(
|
|
||||||
"Provide your current team password (or your password) to update your team's password"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
affiliation = StringField(
|
|
||||||
_l("Affiliation"),
|
|
||||||
description=_l(
|
|
||||||
"Your team's affiliation publicly shown to other competitors"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
website = URLField(
|
|
||||||
_l("Website"),
|
|
||||||
description=_l("Your team's website publicly shown to other competitors"),
|
|
||||||
)
|
|
||||||
country = SelectField(
|
|
||||||
_l("Country"),
|
|
||||||
choices=SELECT_COUNTRIES_LIST,
|
|
||||||
description=_l("Your team's country publicly shown to other competitors"),
|
|
||||||
)
|
|
||||||
submit = SubmitField(_l("Submit"))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def extra(self):
|
|
||||||
fields_kwargs = _TeamSettingsForm.get_field_kwargs()
|
|
||||||
return build_custom_team_fields(
|
|
||||||
self,
|
|
||||||
include_entries=True,
|
|
||||||
fields_kwargs=fields_kwargs,
|
|
||||||
field_entries_kwargs={"team_id": self.obj.id},
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_field_kwargs():
|
|
||||||
team = get_current_team()
|
|
||||||
field_kwargs = {"editable": True}
|
|
||||||
if team.filled_all_required_fields is False:
|
|
||||||
# Show all fields
|
|
||||||
field_kwargs = {}
|
|
||||||
return field_kwargs
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
"""
|
|
||||||
Custom init to persist the obj parameter to the rest of the form
|
|
||||||
"""
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
obj = kwargs.get("obj")
|
|
||||||
if obj:
|
|
||||||
self.obj = obj
|
|
||||||
|
|
||||||
field_kwargs = _TeamSettingsForm.get_field_kwargs()
|
|
||||||
attach_custom_team_fields(_TeamSettingsForm, **field_kwargs)
|
|
||||||
|
|
||||||
return _TeamSettingsForm(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class TeamCaptainForm(BaseForm):
|
|
||||||
# Choices are populated dynamically at form creation time
|
|
||||||
captain_id = SelectField(
|
|
||||||
_l("Team Captain"), choices=[], validators=[InputRequired()]
|
|
||||||
)
|
|
||||||
submit = SubmitField("Submit")
|
|
||||||
|
|
||||||
|
|
||||||
class TeamSearchForm(BaseForm):
|
|
||||||
field = SelectField(
|
|
||||||
"Search Field",
|
|
||||||
choices=[
|
|
||||||
("name", "Name"),
|
|
||||||
("id", "ID"),
|
|
||||||
("affiliation", "Affiliation"),
|
|
||||||
("website", "Website"),
|
|
||||||
],
|
|
||||||
default="name",
|
|
||||||
validators=[InputRequired()],
|
|
||||||
)
|
|
||||||
q = StringField("Parameter", validators=[InputRequired()])
|
|
||||||
submit = SubmitField("Search")
|
|
||||||
|
|
||||||
|
|
||||||
class PublicTeamSearchForm(BaseForm):
|
|
||||||
field = SelectField(
|
|
||||||
_l("Search Field"),
|
|
||||||
choices=[
|
|
||||||
("name", _l("Name")),
|
|
||||||
("affiliation", _l("Affiliation")),
|
|
||||||
("website", _l("Website")),
|
|
||||||
],
|
|
||||||
default="name",
|
|
||||||
validators=[InputRequired()],
|
|
||||||
)
|
|
||||||
q = StringField(_l("Parameter"), validators=[InputRequired()])
|
|
||||||
submit = SubmitField(_l("Search"))
|
|
||||||
|
|
||||||
|
|
||||||
class TeamBaseForm(BaseForm):
|
|
||||||
name = StringField(_l("Team Name"), validators=[InputRequired()])
|
|
||||||
email = EmailField(_l("Email"))
|
|
||||||
password = PasswordField(_l("Password"))
|
|
||||||
website = URLField(_l("Website"))
|
|
||||||
affiliation = StringField(_l("Affiliation"))
|
|
||||||
country = SelectField(_l("Country"), choices=SELECT_COUNTRIES_LIST)
|
|
||||||
hidden = BooleanField(_l("Hidden"))
|
|
||||||
banned = BooleanField(_l("Banned"))
|
|
||||||
submit = SubmitField(_l("Submit"))
|
|
||||||
|
|
||||||
|
|
||||||
def TeamCreateForm(*args, **kwargs):
|
|
||||||
class _TeamCreateForm(TeamBaseForm):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@property
|
|
||||||
def extra(self):
|
|
||||||
return build_custom_team_fields(self, include_entries=False)
|
|
||||||
|
|
||||||
attach_custom_team_fields(_TeamCreateForm)
|
|
||||||
|
|
||||||
return _TeamCreateForm(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def TeamEditForm(*args, **kwargs):
|
|
||||||
class _TeamEditForm(TeamBaseForm):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@property
|
|
||||||
def extra(self):
|
|
||||||
return build_custom_team_fields(
|
|
||||||
self,
|
|
||||||
include_entries=True,
|
|
||||||
fields_kwargs=None,
|
|
||||||
field_entries_kwargs={"team_id": self.obj.id},
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
"""
|
|
||||||
Custom init to persist the obj parameter to the rest of the form
|
|
||||||
"""
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
obj = kwargs.get("obj")
|
|
||||||
if obj:
|
|
||||||
self.obj = obj
|
|
||||||
|
|
||||||
attach_custom_team_fields(_TeamEditForm)
|
|
||||||
|
|
||||||
return _TeamEditForm(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class TeamInviteForm(BaseForm):
|
|
||||||
link = URLField(_l("Invite Link"))
|
|
||||||
|
|
||||||
|
|
||||||
class TeamInviteJoinForm(BaseForm):
|
|
||||||
submit = SubmitField(_l("Join"))
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
from flask_babel import lazy_gettext as _l
|
|
||||||
from wtforms import BooleanField, PasswordField, SelectField, StringField
|
|
||||||
from wtforms.fields.html5 import EmailField
|
|
||||||
from wtforms.validators import InputRequired
|
|
||||||
|
|
||||||
from CTFd.constants.config import Configs
|
|
||||||
from CTFd.constants.languages import SELECT_LANGUAGE_LIST
|
|
||||||
from CTFd.forms import BaseForm
|
|
||||||
from CTFd.forms.fields import SubmitField
|
|
||||||
from CTFd.models import UserFieldEntries, UserFields
|
|
||||||
from CTFd.utils.countries import SELECT_COUNTRIES_LIST
|
|
||||||
|
|
||||||
|
|
||||||
def build_custom_user_fields(
|
|
||||||
form_cls,
|
|
||||||
include_entries=False,
|
|
||||||
fields_kwargs=None,
|
|
||||||
field_entries_kwargs=None,
|
|
||||||
blacklisted_items=("affiliation", "website"),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Function used to reinject values back into forms for accessing by themes
|
|
||||||
"""
|
|
||||||
if fields_kwargs is None:
|
|
||||||
fields_kwargs = {}
|
|
||||||
if field_entries_kwargs is None:
|
|
||||||
field_entries_kwargs = {}
|
|
||||||
|
|
||||||
fields = []
|
|
||||||
new_fields = UserFields.query.filter_by(**fields_kwargs).all()
|
|
||||||
user_fields = {}
|
|
||||||
|
|
||||||
# Only include preexisting values if asked
|
|
||||||
if include_entries is True:
|
|
||||||
for f in UserFieldEntries.query.filter_by(**field_entries_kwargs).all():
|
|
||||||
user_fields[f.field_id] = f.value
|
|
||||||
|
|
||||||
for field in new_fields:
|
|
||||||
if field.name.lower() in blacklisted_items:
|
|
||||||
continue
|
|
||||||
|
|
||||||
form_field = getattr(form_cls, f"fields[{field.id}]")
|
|
||||||
|
|
||||||
# Add the field_type to the field so we know how to render it
|
|
||||||
form_field.field_type = field.field_type
|
|
||||||
|
|
||||||
# Only include preexisting values if asked
|
|
||||||
if include_entries is True:
|
|
||||||
initial = user_fields.get(field.id, "")
|
|
||||||
form_field.data = initial
|
|
||||||
if form_field.render_kw:
|
|
||||||
form_field.render_kw["data-initial"] = initial
|
|
||||||
else:
|
|
||||||
form_field.render_kw = {"data-initial": initial}
|
|
||||||
|
|
||||||
fields.append(form_field)
|
|
||||||
return fields
|
|
||||||
|
|
||||||
|
|
||||||
def attach_custom_user_fields(form_cls, **kwargs):
|
|
||||||
"""
|
|
||||||
Function used to attach form fields to wtforms.
|
|
||||||
Not really a great solution but is approved by wtforms.
|
|
||||||
|
|
||||||
https://wtforms.readthedocs.io/en/2.3.x/specific_problems/#dynamic-form-composition
|
|
||||||
"""
|
|
||||||
new_fields = UserFields.query.filter_by(**kwargs).all()
|
|
||||||
for field in new_fields:
|
|
||||||
validators = []
|
|
||||||
if field.required:
|
|
||||||
validators.append(InputRequired())
|
|
||||||
|
|
||||||
if field.field_type == "text":
|
|
||||||
input_field = StringField(
|
|
||||||
field.name, description=field.description, validators=validators
|
|
||||||
)
|
|
||||||
elif field.field_type == "boolean":
|
|
||||||
input_field = BooleanField(
|
|
||||||
field.name, description=field.description, validators=validators
|
|
||||||
)
|
|
||||||
|
|
||||||
setattr(form_cls, f"fields[{field.id}]", input_field)
|
|
||||||
|
|
||||||
|
|
||||||
def build_registration_code_field(form_cls):
|
|
||||||
"""
|
|
||||||
Build the appropriate field so we can render it via the extra property.
|
|
||||||
Add field_type so Jinja knows how to render it.
|
|
||||||
"""
|
|
||||||
if Configs.registration_code:
|
|
||||||
field = getattr(form_cls, "registration_code") # noqa B009
|
|
||||||
field.field_type = "text"
|
|
||||||
return [field]
|
|
||||||
else:
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def attach_registration_code_field(form_cls):
|
|
||||||
"""
|
|
||||||
If we have a registration code required, we attach it to the form similar
|
|
||||||
to attach_custom_user_fields
|
|
||||||
"""
|
|
||||||
if Configs.registration_code:
|
|
||||||
setattr( # noqa B010
|
|
||||||
form_cls,
|
|
||||||
"registration_code",
|
|
||||||
StringField(
|
|
||||||
"Registration Code",
|
|
||||||
description="Registration code required to create account",
|
|
||||||
validators=[InputRequired()],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class UserSearchForm(BaseForm):
|
|
||||||
field = SelectField(
|
|
||||||
"Search Field",
|
|
||||||
choices=[
|
|
||||||
("name", "Name"),
|
|
||||||
("id", "ID"),
|
|
||||||
("email", "Email"),
|
|
||||||
("affiliation", "Affiliation"),
|
|
||||||
("website", "Website"),
|
|
||||||
("ip", "IP Address"),
|
|
||||||
],
|
|
||||||
default="name",
|
|
||||||
validators=[InputRequired()],
|
|
||||||
)
|
|
||||||
q = StringField("Parameter", validators=[InputRequired()])
|
|
||||||
submit = SubmitField("Search")
|
|
||||||
|
|
||||||
|
|
||||||
class PublicUserSearchForm(BaseForm):
|
|
||||||
field = SelectField(
|
|
||||||
_l("Search Field"),
|
|
||||||
choices=[
|
|
||||||
("name", _l("Name")),
|
|
||||||
("affiliation", _l("Affiliation")),
|
|
||||||
("website", _l("Website")),
|
|
||||||
],
|
|
||||||
default="name",
|
|
||||||
validators=[InputRequired()],
|
|
||||||
)
|
|
||||||
q = StringField(
|
|
||||||
_l("Parameter"),
|
|
||||||
description=_l("Search for matching users"),
|
|
||||||
validators=[InputRequired()],
|
|
||||||
)
|
|
||||||
submit = SubmitField(_l("Search"))
|
|
||||||
|
|
||||||
|
|
||||||
class UserBaseForm(BaseForm):
|
|
||||||
name = StringField("User Name", validators=[InputRequired()])
|
|
||||||
email = EmailField("Email", validators=[InputRequired()])
|
|
||||||
language = SelectField(_l("Language"), choices=SELECT_LANGUAGE_LIST)
|
|
||||||
password = PasswordField("Password")
|
|
||||||
website = StringField("Website")
|
|
||||||
affiliation = StringField("Affiliation")
|
|
||||||
country = SelectField("Country", choices=SELECT_COUNTRIES_LIST)
|
|
||||||
type = SelectField("Type", choices=[("user", "User"), ("admin", "Admin")])
|
|
||||||
verified = BooleanField("Verified")
|
|
||||||
hidden = BooleanField("Hidden")
|
|
||||||
banned = BooleanField("Banned")
|
|
||||||
submit = SubmitField("Submit")
|
|
||||||
|
|
||||||
|
|
||||||
def UserEditForm(*args, **kwargs):
|
|
||||||
class _UserEditForm(UserBaseForm):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@property
|
|
||||||
def extra(self):
|
|
||||||
return build_custom_user_fields(
|
|
||||||
self,
|
|
||||||
include_entries=True,
|
|
||||||
fields_kwargs=None,
|
|
||||||
field_entries_kwargs={"user_id": self.obj.id},
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
"""
|
|
||||||
Custom init to persist the obj parameter to the rest of the form
|
|
||||||
"""
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
obj = kwargs.get("obj")
|
|
||||||
if obj:
|
|
||||||
self.obj = obj
|
|
||||||
|
|
||||||
attach_custom_user_fields(_UserEditForm)
|
|
||||||
|
|
||||||
return _UserEditForm(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def UserCreateForm(*args, **kwargs):
|
|
||||||
class _UserCreateForm(UserBaseForm):
|
|
||||||
notify = BooleanField("Email account credentials to user", default=True)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def extra(self):
|
|
||||||
return build_custom_user_fields(self, include_entries=False)
|
|
||||||
|
|
||||||
attach_custom_user_fields(_UserCreateForm)
|
|
||||||
|
|
||||||
return _UserCreateForm(*args, **kwargs)
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,209 +0,0 @@
|
|||||||
import glob
|
|
||||||
import importlib
|
|
||||||
import os
|
|
||||||
from collections import namedtuple
|
|
||||||
|
|
||||||
from flask import current_app as app
|
|
||||||
from flask import send_file, send_from_directory, url_for
|
|
||||||
|
|
||||||
from CTFd.utils.config.pages import get_pages
|
|
||||||
from CTFd.utils.decorators import admins_only as admins_only_wrapper
|
|
||||||
from CTFd.utils.plugins import override_template as utils_override_template
|
|
||||||
from CTFd.utils.plugins import (
|
|
||||||
register_admin_script as utils_register_admin_plugin_script,
|
|
||||||
)
|
|
||||||
from CTFd.utils.plugins import (
|
|
||||||
register_admin_stylesheet as utils_register_admin_plugin_stylesheet,
|
|
||||||
)
|
|
||||||
from CTFd.utils.plugins import register_script as utils_register_plugin_script
|
|
||||||
from CTFd.utils.plugins import register_stylesheet as utils_register_plugin_stylesheet
|
|
||||||
|
|
||||||
Menu = namedtuple("Menu", ["title", "route"])
|
|
||||||
|
|
||||||
|
|
||||||
def register_plugin_assets_directory(app, base_path, admins_only=False, endpoint=None):
|
|
||||||
"""
|
|
||||||
Registers a directory to serve assets
|
|
||||||
|
|
||||||
:param app: A CTFd application
|
|
||||||
:param string base_path: The path to the directory
|
|
||||||
:param boolean admins_only: Whether or not the assets served out of the directory should be accessible to the public
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
base_path = base_path.strip("/")
|
|
||||||
if endpoint is None:
|
|
||||||
endpoint = base_path.replace("/", ".")
|
|
||||||
|
|
||||||
def assets_handler(path):
|
|
||||||
return send_from_directory(base_path, path)
|
|
||||||
|
|
||||||
rule = "/" + base_path + "/<path:path>"
|
|
||||||
app.add_url_rule(rule=rule, endpoint=endpoint, view_func=assets_handler)
|
|
||||||
|
|
||||||
|
|
||||||
def register_plugin_asset(app, asset_path, admins_only=False, endpoint=None):
|
|
||||||
"""
|
|
||||||
Registers an file path to be served by CTFd
|
|
||||||
|
|
||||||
:param app: A CTFd application
|
|
||||||
:param string asset_path: The path to the asset file
|
|
||||||
:param boolean admins_only: Whether or not this file should be accessible to the public
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
asset_path = asset_path.strip("/")
|
|
||||||
if endpoint is None:
|
|
||||||
endpoint = asset_path.replace("/", ".")
|
|
||||||
|
|
||||||
def asset_handler():
|
|
||||||
return send_file(asset_path)
|
|
||||||
|
|
||||||
if admins_only:
|
|
||||||
asset_handler = admins_only_wrapper(asset_handler)
|
|
||||||
rule = "/" + asset_path
|
|
||||||
app.add_url_rule(rule=rule, endpoint=endpoint, view_func=asset_handler)
|
|
||||||
|
|
||||||
|
|
||||||
def override_template(*args, **kwargs):
|
|
||||||
"""
|
|
||||||
Overrides a template with the provided html content.
|
|
||||||
|
|
||||||
e.g. override_template('scoreboard.html', '<h1>scores</h1>')
|
|
||||||
"""
|
|
||||||
utils_override_template(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def register_plugin_script(*args, **kwargs):
|
|
||||||
"""
|
|
||||||
Adds a given script to the base.html template which all pages inherit from
|
|
||||||
"""
|
|
||||||
utils_register_plugin_script(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def register_plugin_stylesheet(*args, **kwargs):
|
|
||||||
"""
|
|
||||||
Adds a given stylesheet to the base.html template which all pages inherit from.
|
|
||||||
"""
|
|
||||||
utils_register_plugin_stylesheet(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def register_admin_plugin_script(*args, **kwargs):
|
|
||||||
"""
|
|
||||||
Adds a given script to the base.html of the admin theme which all admin pages inherit from
|
|
||||||
:param args:
|
|
||||||
:param kwargs:
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
utils_register_admin_plugin_script(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def register_admin_plugin_stylesheet(*args, **kwargs):
|
|
||||||
"""
|
|
||||||
Adds a given stylesheet to the base.html of the admin theme which all admin pages inherit from
|
|
||||||
:param args:
|
|
||||||
:param kwargs:
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
utils_register_admin_plugin_stylesheet(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def register_admin_plugin_menu_bar(title, route):
|
|
||||||
"""
|
|
||||||
Registers links on the Admin Panel menubar/navbar
|
|
||||||
|
|
||||||
:param name: A string that is shown on the navbar HTML
|
|
||||||
:param route: A string that is the href used by the link
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
am = Menu(title=title, route=route)
|
|
||||||
app.admin_plugin_menu_bar.append(am)
|
|
||||||
|
|
||||||
|
|
||||||
def get_admin_plugin_menu_bar():
|
|
||||||
"""
|
|
||||||
Access the list used to store the plugin menu bar
|
|
||||||
|
|
||||||
:return: Returns a list of Menu namedtuples. They have name, and route attributes.
|
|
||||||
"""
|
|
||||||
return app.admin_plugin_menu_bar
|
|
||||||
|
|
||||||
|
|
||||||
def register_user_page_menu_bar(title, route):
|
|
||||||
"""
|
|
||||||
Registers links on the User side menubar/navbar
|
|
||||||
|
|
||||||
:param name: A string that is shown on the navbar HTML
|
|
||||||
:param route: A string that is the href used by the link
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
p = Menu(title=title, route=route)
|
|
||||||
app.plugin_menu_bar.append(p)
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_page_menu_bar():
|
|
||||||
"""
|
|
||||||
Access the list used to store the user page menu bar
|
|
||||||
|
|
||||||
:return: Returns a list of Menu namedtuples. They have name, and route attributes.
|
|
||||||
"""
|
|
||||||
pages = []
|
|
||||||
for p in get_pages() + app.plugin_menu_bar:
|
|
||||||
if p.route.startswith("http"):
|
|
||||||
route = p.route
|
|
||||||
else:
|
|
||||||
route = url_for("views.static_html", route=p.route)
|
|
||||||
pages.append(Menu(title=p.title, route=route))
|
|
||||||
return pages
|
|
||||||
|
|
||||||
|
|
||||||
def bypass_csrf_protection(f):
|
|
||||||
"""
|
|
||||||
Decorator that allows a route to bypass the need for a CSRF nonce on POST requests.
|
|
||||||
|
|
||||||
This should be considered beta and may change in future versions.
|
|
||||||
|
|
||||||
:param f: A function that needs to bypass CSRF protection
|
|
||||||
:return: Returns a function with the _bypass_csrf attribute set which tells CTFd to not require CSRF protection.
|
|
||||||
"""
|
|
||||||
f._bypass_csrf = True
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
def get_plugin_names():
|
|
||||||
modules = sorted(glob.glob(app.plugins_dir + "/*"))
|
|
||||||
blacklist = {"__pycache__"}
|
|
||||||
plugins = []
|
|
||||||
for module in modules:
|
|
||||||
module_name = os.path.basename(module)
|
|
||||||
if os.path.isdir(module) and module_name not in blacklist:
|
|
||||||
plugins.append(module_name)
|
|
||||||
return plugins
|
|
||||||
|
|
||||||
|
|
||||||
def init_plugins(app):
|
|
||||||
"""
|
|
||||||
Searches for the load function in modules in the CTFd/plugins folder. This function is called with the current CTFd
|
|
||||||
app as a parameter. This allows CTFd plugins to modify CTFd's behavior.
|
|
||||||
|
|
||||||
:param app: A CTFd application
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
app.admin_plugin_scripts = []
|
|
||||||
app.admin_plugin_stylesheets = []
|
|
||||||
app.plugin_scripts = []
|
|
||||||
app.plugin_stylesheets = []
|
|
||||||
|
|
||||||
app.admin_plugin_menu_bar = []
|
|
||||||
app.plugin_menu_bar = []
|
|
||||||
app.plugins_dir = os.path.dirname(__file__)
|
|
||||||
|
|
||||||
if app.config.get("SAFE_MODE", False) is False:
|
|
||||||
for plugin in get_plugin_names():
|
|
||||||
module = "." + plugin
|
|
||||||
module = importlib.import_module(module, package="CTFd.plugins")
|
|
||||||
module.load(app)
|
|
||||||
print(" * Loaded module, %s" % module)
|
|
||||||
else:
|
|
||||||
print("SAFE_MODE is enabled. Skipping plugin loading.")
|
|
||||||
|
|
||||||
app.jinja_env.globals.update(get_admin_plugin_menu_bar=get_admin_plugin_menu_bar)
|
|
||||||
app.jinja_env.globals.update(get_user_page_menu_bar=get_user_page_menu_bar)
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
from flask import Blueprint
|
|
||||||
|
|
||||||
from CTFd.models import (
|
|
||||||
ChallengeFiles,
|
|
||||||
Challenges,
|
|
||||||
Fails,
|
|
||||||
Flags,
|
|
||||||
Hints,
|
|
||||||
Solves,
|
|
||||||
Tags,
|
|
||||||
db,
|
|
||||||
)
|
|
||||||
from CTFd.plugins import register_plugin_assets_directory
|
|
||||||
from CTFd.plugins.flags import FlagException, get_flag_class
|
|
||||||
from CTFd.utils.uploads import delete_file
|
|
||||||
from CTFd.utils.user import get_ip
|
|
||||||
|
|
||||||
|
|
||||||
class BaseChallenge(object):
|
|
||||||
id = None
|
|
||||||
name = None
|
|
||||||
templates = {}
|
|
||||||
scripts = {}
|
|
||||||
challenge_model = Challenges
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create(cls, request):
|
|
||||||
"""
|
|
||||||
This method is used to process the challenge creation request.
|
|
||||||
|
|
||||||
:param request:
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
data = request.form or request.get_json()
|
|
||||||
|
|
||||||
challenge = cls.challenge_model(**data)
|
|
||||||
|
|
||||||
db.session.add(challenge)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return challenge
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def read(cls, challenge):
|
|
||||||
"""
|
|
||||||
This method is in used to access the data of a challenge in a format processable by the front end.
|
|
||||||
|
|
||||||
:param challenge:
|
|
||||||
:return: Challenge object, data dictionary to be returned to the user
|
|
||||||
"""
|
|
||||||
data = {
|
|
||||||
"id": challenge.id,
|
|
||||||
"name": challenge.name,
|
|
||||||
"value": challenge.value,
|
|
||||||
"description": challenge.description,
|
|
||||||
"connection_info": challenge.connection_info,
|
|
||||||
"next_id": challenge.next_id,
|
|
||||||
"category": challenge.category,
|
|
||||||
"state": challenge.state,
|
|
||||||
"max_attempts": challenge.max_attempts,
|
|
||||||
"type": challenge.type,
|
|
||||||
"type_data": {
|
|
||||||
"id": cls.id,
|
|
||||||
"name": cls.name,
|
|
||||||
"templates": cls.templates,
|
|
||||||
"scripts": cls.scripts,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def update(cls, challenge, request):
|
|
||||||
"""
|
|
||||||
This method is used to update the information associated with a challenge. This should be kept strictly to the
|
|
||||||
Challenges table and any child tables.
|
|
||||||
|
|
||||||
:param challenge:
|
|
||||||
:param request:
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
data = request.form or request.get_json()
|
|
||||||
for attr, value in data.items():
|
|
||||||
setattr(challenge, attr, value)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
return challenge
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def delete(cls, challenge):
|
|
||||||
"""
|
|
||||||
This method is used to delete the resources used by a challenge.
|
|
||||||
|
|
||||||
:param challenge:
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
Fails.query.filter_by(challenge_id=challenge.id).delete()
|
|
||||||
Solves.query.filter_by(challenge_id=challenge.id).delete()
|
|
||||||
Flags.query.filter_by(challenge_id=challenge.id).delete()
|
|
||||||
files = ChallengeFiles.query.filter_by(challenge_id=challenge.id).all()
|
|
||||||
for f in files:
|
|
||||||
delete_file(f.id)
|
|
||||||
ChallengeFiles.query.filter_by(challenge_id=challenge.id).delete()
|
|
||||||
Tags.query.filter_by(challenge_id=challenge.id).delete()
|
|
||||||
Hints.query.filter_by(challenge_id=challenge.id).delete()
|
|
||||||
Challenges.query.filter_by(id=challenge.id).delete()
|
|
||||||
cls.challenge_model.query.filter_by(id=challenge.id).delete()
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def attempt(cls, challenge, request):
|
|
||||||
"""
|
|
||||||
This method is used to check whether a given input is right or wrong. It does not make any changes and should
|
|
||||||
return a boolean for correctness and a string to be shown to the user. It is also in charge of parsing the
|
|
||||||
user's input from the request itself.
|
|
||||||
|
|
||||||
:param challenge: The Challenge object from the database
|
|
||||||
:param request: The request the user submitted
|
|
||||||
:return: (boolean, string)
|
|
||||||
"""
|
|
||||||
data = request.form or request.get_json()
|
|
||||||
submission = data["submission"].strip()
|
|
||||||
flags = Flags.query.filter_by(challenge_id=challenge.id).all()
|
|
||||||
for flag in flags:
|
|
||||||
try:
|
|
||||||
if get_flag_class(flag.type).compare(flag, submission):
|
|
||||||
return True, "Correct"
|
|
||||||
except FlagException as e:
|
|
||||||
return False, str(e)
|
|
||||||
return False, "Incorrect"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def solve(cls, user, team, challenge, request):
|
|
||||||
"""
|
|
||||||
This method is used to insert Solves into the database in order to mark a challenge as solved.
|
|
||||||
|
|
||||||
:param team: The Team object from the database
|
|
||||||
:param chal: The Challenge object from the database
|
|
||||||
:param request: The request the user submitted
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
data = request.form or request.get_json()
|
|
||||||
submission = data["submission"].strip()
|
|
||||||
solve = Solves(
|
|
||||||
user_id=user.id,
|
|
||||||
team_id=team.id if team else None,
|
|
||||||
challenge_id=challenge.id,
|
|
||||||
ip=get_ip(req=request),
|
|
||||||
provided=submission,
|
|
||||||
)
|
|
||||||
db.session.add(solve)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def fail(cls, user, team, challenge, request):
|
|
||||||
"""
|
|
||||||
This method is used to insert Fails into the database in order to mark an answer incorrect.
|
|
||||||
|
|
||||||
:param team: The Team object from the database
|
|
||||||
:param chal: The Challenge object from the database
|
|
||||||
:param request: The request the user submitted
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
data = request.form or request.get_json()
|
|
||||||
submission = data["submission"].strip()
|
|
||||||
wrong = Fails(
|
|
||||||
user_id=user.id,
|
|
||||||
team_id=team.id if team else None,
|
|
||||||
challenge_id=challenge.id,
|
|
||||||
ip=get_ip(request),
|
|
||||||
provided=submission,
|
|
||||||
)
|
|
||||||
db.session.add(wrong)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
class CTFdStandardChallenge(BaseChallenge):
|
|
||||||
id = "standard" # Unique identifier used to register challenges
|
|
||||||
name = "standard" # Name of a challenge type
|
|
||||||
templates = { # Templates used for each aspect of challenge editing & viewing
|
|
||||||
"create": "/plugins/challenges/assets/create.html",
|
|
||||||
"update": "/plugins/challenges/assets/update.html",
|
|
||||||
"view": "/plugins/challenges/assets/view.html",
|
|
||||||
}
|
|
||||||
scripts = { # Scripts that are loaded when a template is loaded
|
|
||||||
"create": "/plugins/challenges/assets/create.js",
|
|
||||||
"update": "/plugins/challenges/assets/update.js",
|
|
||||||
"view": "/plugins/challenges/assets/view.js",
|
|
||||||
}
|
|
||||||
# Route at which files are accessible. This must be registered using register_plugin_assets_directory()
|
|
||||||
route = "/plugins/challenges/assets/"
|
|
||||||
# Blueprint used to access the static_folder directory.
|
|
||||||
blueprint = Blueprint(
|
|
||||||
"standard", __name__, template_folder="templates", static_folder="assets"
|
|
||||||
)
|
|
||||||
challenge_model = Challenges
|
|
||||||
|
|
||||||
|
|
||||||
def get_chal_class(class_id):
|
|
||||||
"""
|
|
||||||
Utility function used to get the corresponding class from a class ID.
|
|
||||||
|
|
||||||
:param class_id: String representing the class ID
|
|
||||||
:return: Challenge class
|
|
||||||
"""
|
|
||||||
cls = CHALLENGE_CLASSES.get(class_id)
|
|
||||||
if cls is None:
|
|
||||||
raise KeyError
|
|
||||||
return cls
|
|
||||||
|
|
||||||
|
|
||||||
"""
|
|
||||||
Global dictionary used to hold all the Challenge Type classes used by CTFd. Insert into this dictionary to register
|
|
||||||
your Challenge Type.
|
|
||||||
"""
|
|
||||||
CHALLENGE_CLASSES = {"standard": CTFdStandardChallenge}
|
|
||||||
|
|
||||||
|
|
||||||
def load(app):
|
|
||||||
register_plugin_assets_directory(app, base_path="/plugins/challenges/assets/")
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{% extends "admin/challenges/create.html" %}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
CTFd.plugin.run((_CTFd) => {
|
|
||||||
const $ = _CTFd.lib.$
|
|
||||||
const md = _CTFd.lib.markdown()
|
|
||||||
})
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{% extends "admin/challenges/update.html" %}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{% extends "challenge.html" %}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
CTFd._internal.challenge.data = undefined;
|
|
||||||
|
|
||||||
// TODO: Remove in CTFd v4.0
|
|
||||||
CTFd._internal.challenge.renderer = null;
|
|
||||||
|
|
||||||
CTFd._internal.challenge.preRender = function() {};
|
|
||||||
|
|
||||||
// TODO: Remove in CTFd v4.0
|
|
||||||
CTFd._internal.challenge.render = null;
|
|
||||||
|
|
||||||
CTFd._internal.challenge.postRender = function() {};
|
|
||||||
|
|
||||||
CTFd._internal.challenge.submit = function(preview) {
|
|
||||||
var challenge_id = parseInt(CTFd.lib.$("#challenge-id").val());
|
|
||||||
var submission = CTFd.lib.$("#challenge-input").val();
|
|
||||||
|
|
||||||
var body = {
|
|
||||||
challenge_id: challenge_id,
|
|
||||||
submission: submission
|
|
||||||
};
|
|
||||||
var params = {};
|
|
||||||
if (preview) {
|
|
||||||
params["preview"] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return CTFd.api.post_challenge_attempt(params, body).then(function(response) {
|
|
||||||
if (response.status === 429) {
|
|
||||||
// User was ratelimited but process response
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
if (response.status === 403) {
|
|
||||||
// User is not logged in or CTF is paused.
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user