init CTFd source
This commit is contained in:
9
.codecov.yml
Normal file
9
.codecov.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
# Fail the status if coverage drops by >= 1%
|
||||
threshold: 1
|
||||
patch:
|
||||
default:
|
||||
threshold: 1
|
||||
19
.dockerignore
Normal file
19
.dockerignore
Normal file
@@ -0,0 +1,19 @@
|
||||
CTFd/logs/*.log
|
||||
CTFd/static/uploads
|
||||
CTFd/uploads
|
||||
CTFd/*.db
|
||||
CTFd/uploads/**/*
|
||||
.ctfd_secret_key
|
||||
.data
|
||||
.git
|
||||
.codecov.yml
|
||||
.dockerignore
|
||||
.github
|
||||
.gitignore
|
||||
.prettierignore
|
||||
.travis.yml
|
||||
**/node_modules
|
||||
**/*.pyc
|
||||
**/__pycache__
|
||||
.venv*
|
||||
venv*
|
||||
18
.eslintrc.js
Normal file
18
.eslintrc.js
Normal file
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true
|
||||
},
|
||||
"extends": "eslint:recommended",
|
||||
"globals": {
|
||||
"Atomics": "readonly",
|
||||
"SharedArrayBuffer": "readonly"
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2018,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
|
||||
}
|
||||
};
|
||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: CTFd
|
||||
19
.github/ISSUE_TEMPLATE.md
vendored
Normal file
19
.github/ISSUE_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
<!--
|
||||
If this is a bug report please fill out the template below.
|
||||
|
||||
If this is a feature request please describe the behavior that you'd like to see.
|
||||
-->
|
||||
|
||||
**Environment**:
|
||||
|
||||
- CTFd Version/Commit:
|
||||
- Operating System:
|
||||
- Web Browser and Version:
|
||||
|
||||
**What happened?**
|
||||
|
||||
**What did you expect to happen?**
|
||||
|
||||
**How to reproduce your issue**
|
||||
|
||||
**Any associated stack traces or error logs**
|
||||
46
.github/workflows/docker-build.yml
vendored
Normal file
46
.github/workflows/docker-build.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
name: Docker build image on release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Set repo name lowercase
|
||||
id: repo
|
||||
uses: ASzc/change-string-case-action@v2
|
||||
with:
|
||||
string: ${{ github.repository }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ steps.repo.outputs.lowercase }}:latest
|
||||
ghcr.io/${{ steps.repo.outputs.lowercase }}:latest
|
||||
${{ steps.repo.outputs.lowercase }}:${{ github.event.release.tag_name }}
|
||||
ghcr.io/${{ steps.repo.outputs.lowercase }}:${{ github.event.release.tag_name }}
|
||||
47
.github/workflows/lint.yml
vendored
Normal file
47
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: Linting
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.11']
|
||||
|
||||
name: Linting
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
architecture: x64
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -r linting.txt
|
||||
sudo yarn --cwd CTFd/themes/admin install --non-interactive
|
||||
sudo yarn global add prettier@3.2.5
|
||||
|
||||
- name: Lint
|
||||
run: make lint
|
||||
env:
|
||||
TESTING_DATABASE_URL: 'sqlite://'
|
||||
|
||||
- name: Lint Dockerfile
|
||||
uses: brpaz/hadolint-action@master
|
||||
with:
|
||||
dockerfile: "Dockerfile"
|
||||
|
||||
- name: Lint docker-compose
|
||||
run: |
|
||||
docker compose -f docker-compose.yml config
|
||||
|
||||
- name: Lint translations
|
||||
run: |
|
||||
make translations-lint
|
||||
|
||||
59
.github/workflows/mariadb.yml
vendored
Normal file
59
.github/workflows/mariadb.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
name: CTFd MariaDB CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:10.11
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: password
|
||||
MYSQL_DATABASE: ctfd
|
||||
ports:
|
||||
- 3306
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.11']
|
||||
|
||||
name: Python ${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
architecture: x64
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -r development.txt
|
||||
sudo yarn install --non-interactive
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
sudo rm -f /etc/boto.cfg
|
||||
make test
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
|
||||
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
||||
TESTING_DATABASE_URL: mysql+pymysql://root:password@localhost:${{ job.services.mariadb.ports[3306] }}/ctfd
|
||||
|
||||
- name: Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
30
.github/workflows/mirror-core-theme.yml
vendored
Normal file
30
.github/workflows/mirror-core-theme.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Mirror core-theme
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # need full history for subtree
|
||||
|
||||
- name: Setup SSH for deploy key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.CORE_THEME_DEPLOY_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan github.com >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Setup git identity
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "username@users.noreply.github.com"
|
||||
|
||||
- name: Push subtree
|
||||
run: |
|
||||
git subtree push --prefix="CTFd/themes/core" "git@github.com:CTFd/core-theme.git" main
|
||||
59
.github/workflows/mysql.yml
vendored
Normal file
59
.github/workflows/mysql.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
name: CTFd MySQL CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:5.7
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: password
|
||||
ports:
|
||||
- 3306
|
||||
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.11']
|
||||
|
||||
name: Python ${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
architecture: x64
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -r development.txt
|
||||
sudo yarn install --non-interactive
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
sudo rm -f /etc/boto.cfg
|
||||
make test
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
|
||||
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
||||
TESTING_DATABASE_URL: mysql+pymysql://root:password@localhost:${{ job.services.mysql.ports[3306] }}/ctfd
|
||||
|
||||
- name: Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
59
.github/workflows/mysql8.yml
vendored
Normal file
59
.github/workflows/mysql8.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
name: CTFd MySQL 8.0 CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: password
|
||||
ports:
|
||||
- 3306
|
||||
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.11']
|
||||
|
||||
name: Python ${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
architecture: x64
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -r development.txt
|
||||
sudo yarn install --non-interactive
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
sudo rm -f /etc/boto.cfg
|
||||
make test
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
|
||||
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
||||
TESTING_DATABASE_URL: mysql+pymysql://root:password@localhost:${{ job.services.mysql.ports[3306] }}/ctfd
|
||||
|
||||
- name: Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
67
.github/workflows/postgres.yml
vendored
Normal file
67
.github/workflows/postgres.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: CTFd Postgres CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
env:
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
POSTGRES_DB: ctfd
|
||||
POSTGRES_PASSWORD: password
|
||||
# Set health checks to wait until postgres has started
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.11']
|
||||
|
||||
name: Python ${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
architecture: x64
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -r development.txt
|
||||
sudo yarn install --non-interactive
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
sudo rm -f /etc/boto.cfg
|
||||
make test
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
|
||||
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
||||
TESTING_DATABASE_URL: postgres://postgres:password@localhost:${{ job.services.postgres.ports[5432] }}/ctfd
|
||||
|
||||
- name: Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
|
||||
49
.github/workflows/sqlite.yml
vendored
Normal file
49
.github/workflows/sqlite.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: CTFd SQLite CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.11']
|
||||
|
||||
name: Python ${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
architecture: x64
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -r development.txt
|
||||
sudo yarn install --non-interactive
|
||||
sudo yarn global add prettier@1.17.0
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
sudo rm -f /etc/boto.cfg
|
||||
make test
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
|
||||
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
||||
TESTING_DATABASE_URL: 'sqlite://'
|
||||
|
||||
- name: Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
|
||||
38
.github/workflows/verify-themes.yml
vendored
Normal file
38
.github/workflows/verify-themes.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Theme Verification
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
name: Theme Verification
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19
|
||||
|
||||
- name: Verify admin theme
|
||||
run: |
|
||||
pwd
|
||||
yarn install --non-interactive
|
||||
yarn verify
|
||||
working-directory: ./CTFd/themes/admin
|
||||
|
||||
# TODO: Replace in 4.0 with deprecation of previous core theme
|
||||
- name: Verify core theme
|
||||
run: |
|
||||
pwd
|
||||
yarn install --non-interactive
|
||||
yarn verify
|
||||
working-directory: ./CTFd/themes/core
|
||||
82
.gitignore
vendored
Normal file
82
.gitignore
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
env/
|
||||
venv*
|
||||
.venv*
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
|
||||
# Translations
|
||||
# TODO: CTFd 4.0 We should consider generating .mo files in a Docker image instead of saving them in git
|
||||
# *.mo
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
.DS_Store
|
||||
|
||||
*.db
|
||||
*.log
|
||||
*.log.*
|
||||
.idea/
|
||||
.vscode/
|
||||
CTFd/static/uploads
|
||||
CTFd/uploads
|
||||
.data/
|
||||
.ctfd_secret_key
|
||||
.*.swp
|
||||
|
||||
# Vagrant
|
||||
.vagrant
|
||||
|
||||
# CTFd Exports
|
||||
*.zip
|
||||
|
||||
# JS
|
||||
node_modules/
|
||||
|
||||
# Flask Profiler files
|
||||
flask_profiler.sql
|
||||
7
.isort.cfg
Normal file
7
.isort.cfg
Normal file
@@ -0,0 +1,7 @@
|
||||
[settings]
|
||||
multi_line_output=3
|
||||
include_trailing_comma=True
|
||||
force_grid_wrap=0
|
||||
use_parentheses=True
|
||||
line_length=88
|
||||
skip=migrations
|
||||
17
.prettierignore
Normal file
17
.prettierignore
Normal file
@@ -0,0 +1,17 @@
|
||||
CTFd/themes/**/vendor/
|
||||
CTFd/themes/core-deprecated/
|
||||
CTFd/themes/core/static/
|
||||
CTFd/themes/core-beta/**/*
|
||||
CTFd/themes/admin/static/**/*
|
||||
*.html
|
||||
*.njk
|
||||
*.png
|
||||
*.svg
|
||||
*.ico
|
||||
*.ai
|
||||
*.svg
|
||||
*.mp3
|
||||
*.webm
|
||||
.pytest_cache
|
||||
venv*
|
||||
.venv*
|
||||
2390
CHANGELOG.md
Normal file
2390
CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
21
CONTRIBUTING.md
Normal file
21
CONTRIBUTING.md
Normal file
@@ -0,0 +1,21 @@
|
||||
## How to contribute to CTFd
|
||||
|
||||
#### **Did you find a bug?**
|
||||
|
||||
- **Do not open up a GitHub issue if the bug is a security vulnerability in CTFd**. Instead [email the details to us at support@ctfd.io](mailto:support@ctfd.io).
|
||||
|
||||
- **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/CTFd/CTFd/issues).
|
||||
|
||||
- If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/CTFd/CTFd/issues/new). Be sure to fill out the issue template with a **title and clear description**, and as much relevant information as possible (e.g. deployment setup, browser version, etc).
|
||||
|
||||
#### **Did you write a patch that fixes a bug or implements a new feature?**
|
||||
|
||||
- Open a new pull request with the patch.
|
||||
|
||||
- Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable.
|
||||
|
||||
- Ensure all status checks pass. PR's with test failures will not be merged. PR's with insufficient coverage may be merged depending on the situation.
|
||||
|
||||
#### **Did you fix whitespace, format code, or make a purely cosmetic patch?**
|
||||
|
||||
Changes that are cosmetic in nature and do not add anything substantial to the stability, functionality, or testability of CTFd will generally not be accepted.
|
||||
354
CTFd/__init__.py
Normal file
354
CTFd/__init__.py
Normal file
@@ -0,0 +1,354 @@
|
||||
import datetime
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import weakref
|
||||
from distutils.version import StrictVersion
|
||||
|
||||
import jinja2
|
||||
from flask import Flask, Request
|
||||
from flask_babel import Babel
|
||||
from flask_migrate import upgrade
|
||||
from jinja2 import FileSystemLoader
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from werkzeug.utils import safe_join
|
||||
from werkzeug.wsgi import get_host
|
||||
|
||||
import CTFd.utils.config
|
||||
from CTFd import utils
|
||||
from CTFd.constants.themes import ADMIN_THEME, DEFAULT_THEME
|
||||
from CTFd.plugins import init_plugins
|
||||
from CTFd.utils.crypto import sha256
|
||||
from CTFd.utils.initialization import (
|
||||
init_cli,
|
||||
init_events,
|
||||
init_logs,
|
||||
init_request_processors,
|
||||
init_template_filters,
|
||||
init_template_globals,
|
||||
)
|
||||
from CTFd.utils.migrations import create_database, migrations, stamp_latest_revision
|
||||
from CTFd.utils.sessions import CachingSessionInterface
|
||||
from CTFd.utils.updates import update_check
|
||||
from CTFd.utils.user import get_locale
|
||||
|
||||
__version__ = "3.8.1"
|
||||
__channel__ = "oss"
|
||||
|
||||
|
||||
class CTFdRequest(Request):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
"""
|
||||
Hijack the original Flask request path because it does not account for subdirectory deployments in an intuitive
|
||||
manner. We append script_root so that the path always points to the full path as seen in the browser.
|
||||
e.g. /subdirectory/path/route vs /path/route
|
||||
"""
|
||||
self.path = self.script_root + self.path
|
||||
|
||||
|
||||
class CTFdFlask(Flask):
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Overriden Jinja constructor setting a custom jinja_environment"""
|
||||
self.jinja_environment = SandboxedBaseEnvironment
|
||||
self.session_interface = CachingSessionInterface(key_prefix="session")
|
||||
self.request_class = CTFdRequest
|
||||
|
||||
Flask.__init__(self, *args, **kwargs)
|
||||
|
||||
# Store server start time
|
||||
self.start_time = datetime.datetime.utcnow()
|
||||
|
||||
# Create time-based run identifier.
|
||||
# In production round the timestamp to incrase chances that workers get the same run_id
|
||||
if self.debug:
|
||||
time_based_run_id = str(self.start_time)
|
||||
else:
|
||||
time_based_run_id = str(round(self.start_time.timestamp() / 60) * 60)
|
||||
|
||||
self.time_based_run_id = sha256(time_based_run_id)[0:8]
|
||||
|
||||
@property
|
||||
def run_id(self):
|
||||
# use RUN_ID if exists, otherwise fall back to time_based_run_in
|
||||
return self.config.get("RUN_ID") or self.time_based_run_id
|
||||
|
||||
def create_jinja_environment(self):
|
||||
"""Overridden jinja environment constructor"""
|
||||
return super(CTFdFlask, self).create_jinja_environment()
|
||||
|
||||
def create_url_adapter(self, request):
|
||||
# TODO: Backport of TRUSTED_HOSTS behavior from Flask. Remove when possible.
|
||||
# https://github.com/pallets/flask/pull/5637
|
||||
if request is not None:
|
||||
if (trusted_hosts := self.config.get("TRUSTED_HOSTS")) is not None:
|
||||
request.trusted_hosts = trusted_hosts
|
||||
|
||||
# Check trusted_hosts here until bind_to_environ does.
|
||||
request.host = get_host(request.environ, request.trusted_hosts)
|
||||
return super(CTFdFlask, self).create_url_adapter(request)
|
||||
|
||||
|
||||
class SandboxedBaseEnvironment(SandboxedEnvironment):
|
||||
"""SandboxEnvironment that mimics the Flask BaseEnvironment"""
|
||||
|
||||
def __init__(self, app, **options):
|
||||
if "loader" not in options:
|
||||
options["loader"] = app.create_global_jinja_loader()
|
||||
if "finalize" not in options:
|
||||
# Suppress None into empty strings
|
||||
options["finalize"] = lambda x: x if x is not None else ""
|
||||
SandboxedEnvironment.__init__(self, **options)
|
||||
self.app = app
|
||||
|
||||
def _load_template(self, name, globals):
|
||||
if self.loader is None:
|
||||
raise TypeError("no loader for this environment specified")
|
||||
|
||||
# Add theme to the LRUCache cache key
|
||||
cache_name = name
|
||||
if name.startswith("admin/") is False:
|
||||
theme = str(utils.get_config("ctf_theme"))
|
||||
cache_name = theme + "/" + name
|
||||
|
||||
# Rest of this code roughly copied from Jinja
|
||||
# https://github.com/pallets/jinja/blob/b08cd4bc64bb980df86ed2876978ae5735572280/src/jinja2/environment.py#L956-L973
|
||||
cache_key = (weakref.ref(self.loader), cache_name)
|
||||
if self.cache is not None:
|
||||
template = self.cache.get(cache_key)
|
||||
if template is not None and (
|
||||
not self.auto_reload or template.is_up_to_date
|
||||
):
|
||||
# template.globals is a ChainMap, modifying it will only
|
||||
# affect the template, not the environment globals.
|
||||
if globals:
|
||||
template.globals.update(globals)
|
||||
|
||||
return template
|
||||
|
||||
template = self.loader.load(self, name, self.make_globals(globals))
|
||||
|
||||
if self.cache is not None:
|
||||
self.cache[cache_key] = template
|
||||
return template
|
||||
|
||||
|
||||
class ThemeLoader(FileSystemLoader):
|
||||
"""Custom FileSystemLoader that is aware of theme structure and config."""
|
||||
|
||||
DEFAULT_THEMES_PATH = os.path.join(os.path.dirname(__file__), "themes")
|
||||
_ADMIN_THEME_PREFIX = ADMIN_THEME + "/"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
searchpath=DEFAULT_THEMES_PATH,
|
||||
theme_name=None,
|
||||
encoding="utf-8",
|
||||
followlinks=False,
|
||||
):
|
||||
super(ThemeLoader, self).__init__(searchpath, encoding, followlinks)
|
||||
self.theme_name = theme_name
|
||||
|
||||
def get_source(self, environment, template):
|
||||
# Refuse to load `admin/*` from a loader not for the admin theme
|
||||
# Because there is a single template loader, themes can essentially
|
||||
# provide files for other themes. This could end up causing issues if
|
||||
# an admin theme references a file that doesn't exist that a malicious
|
||||
# theme provides.
|
||||
if template.startswith(self._ADMIN_THEME_PREFIX):
|
||||
if self.theme_name != ADMIN_THEME:
|
||||
raise jinja2.TemplateNotFound(template)
|
||||
template = template[len(self._ADMIN_THEME_PREFIX) :]
|
||||
theme_name = self.theme_name or str(utils.get_config("ctf_theme"))
|
||||
template = safe_join(theme_name, "templates", template)
|
||||
return super(ThemeLoader, self).get_source(environment, template)
|
||||
|
||||
|
||||
def confirm_upgrade():
|
||||
if sys.stdin.isatty():
|
||||
print("/*\\ CTFd has updated and must update the database! /*\\")
|
||||
print("/*\\ Please backup your database before proceeding! /*\\")
|
||||
print("/*\\ CTFd maintainers are not responsible for any data loss! /*\\")
|
||||
if input("Run database migrations (Y/N)").lower().strip() == "y": # nosec B322
|
||||
return True
|
||||
else:
|
||||
print("/*\\ Ignored database migrations... /*\\")
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def run_upgrade():
|
||||
upgrade()
|
||||
utils.set_config("ctf_version", __version__)
|
||||
|
||||
|
||||
def create_app(config="CTFd.config.Config"):
|
||||
app = CTFdFlask(__name__)
|
||||
with app.app_context():
|
||||
app.config.from_object(config)
|
||||
|
||||
from CTFd.cache import cache
|
||||
from CTFd.utils import import_in_progress
|
||||
|
||||
cache.init_app(app)
|
||||
app.cache = cache
|
||||
|
||||
# If we are importing we should pause startup until the import is finished
|
||||
while import_in_progress():
|
||||
print("Import currently in progress, CTFd startup paused for 5 seconds")
|
||||
time.sleep(5)
|
||||
|
||||
loaders = []
|
||||
# We provide a `DictLoader` which may be used to override templates
|
||||
app.overridden_templates = {}
|
||||
loaders.append(jinja2.DictLoader(app.overridden_templates))
|
||||
# A `ThemeLoader` with no `theme_name` will load from the current theme
|
||||
loaders.append(ThemeLoader())
|
||||
# If `THEME_FALLBACK` is set and true, we add another loader which will
|
||||
# load from the `DEFAULT_THEME` - this mirrors the order implemented by
|
||||
# `config.ctf_theme_candidates()`
|
||||
if bool(app.config.get("THEME_FALLBACK")):
|
||||
loaders.append(ThemeLoader(theme_name=DEFAULT_THEME))
|
||||
# All themes including admin can be accessed by prefixing their name
|
||||
prefix_loader_dict = {ADMIN_THEME: ThemeLoader(theme_name=ADMIN_THEME)}
|
||||
for theme_name in CTFd.utils.config.get_themes():
|
||||
prefix_loader_dict[theme_name] = ThemeLoader(theme_name=theme_name)
|
||||
loaders.append(jinja2.PrefixLoader(prefix_loader_dict))
|
||||
# Plugin templates are also accessed via prefix but we just point a
|
||||
# normal `FileSystemLoader` at the plugin tree rather than validating
|
||||
# each plugin here (that happens later in `init_plugins()`). We
|
||||
# deliberately don't add this to `prefix_loader_dict` defined above
|
||||
# because to do so would break template loading from a theme called
|
||||
# `prefix` (even though that'd be weird).
|
||||
plugin_loader = jinja2.FileSystemLoader(
|
||||
searchpath=os.path.join(app.root_path, "plugins"), followlinks=True
|
||||
)
|
||||
loaders.append(jinja2.PrefixLoader({"plugins": plugin_loader}))
|
||||
# Use a choice loader to find the first match from our list of loaders
|
||||
app.jinja_loader = jinja2.ChoiceLoader(loaders)
|
||||
|
||||
from CTFd.models import ( # noqa: F401
|
||||
Challenges,
|
||||
Fails,
|
||||
Files,
|
||||
Flags,
|
||||
Solves,
|
||||
Tags,
|
||||
Teams,
|
||||
Tracking,
|
||||
db,
|
||||
)
|
||||
|
||||
url = create_database()
|
||||
|
||||
# This allows any changes to the SQLALCHEMY_DATABASE_URI to get pushed back in
|
||||
# This is mostly so we can force MySQL's charset
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = str(url)
|
||||
|
||||
# Register database
|
||||
db.init_app(app)
|
||||
|
||||
# Register Flask-Migrate
|
||||
migrations.init_app(app, db)
|
||||
|
||||
babel = Babel()
|
||||
babel.locale_selector_func = get_locale
|
||||
babel.init_app(app)
|
||||
|
||||
# Alembic sqlite support is lacking so we should just create_all anyway
|
||||
if url.drivername.startswith("sqlite"):
|
||||
# Enable foreign keys for SQLite. This must be before the
|
||||
# db.create_all call because tests use the in-memory SQLite
|
||||
# database (each connection, including db creation, is a new db).
|
||||
# https://docs.sqlalchemy.org/en/13/dialects/sqlite.html#foreign-key-support
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
@event.listens_for(Engine, "connect")
|
||||
def set_sqlite_pragma(dbapi_connection, connection_record):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
db.create_all()
|
||||
stamp_latest_revision()
|
||||
else:
|
||||
# This creates tables instead of db.create_all()
|
||||
# Allows migrations to happen properly
|
||||
upgrade()
|
||||
|
||||
from CTFd.models import ma
|
||||
|
||||
ma.init_app(app)
|
||||
|
||||
app.db = db
|
||||
app.VERSION = __version__
|
||||
app.CHANNEL = __channel__
|
||||
|
||||
reverse_proxy = app.config.get("REVERSE_PROXY")
|
||||
if reverse_proxy:
|
||||
if type(reverse_proxy) is str and "," in reverse_proxy:
|
||||
proxyfix_args = [int(i) for i in reverse_proxy.split(",")]
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, *proxyfix_args)
|
||||
else:
|
||||
app.wsgi_app = ProxyFix(
|
||||
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1
|
||||
)
|
||||
|
||||
version = utils.get_config("ctf_version")
|
||||
|
||||
# Upgrading from an older version of CTFd
|
||||
if version and (StrictVersion(version) < StrictVersion(__version__)):
|
||||
if confirm_upgrade():
|
||||
run_upgrade()
|
||||
else:
|
||||
exit()
|
||||
|
||||
if not version:
|
||||
utils.set_config("ctf_version", __version__)
|
||||
|
||||
if not utils.get_config("ctf_theme"):
|
||||
utils.set_config("ctf_theme", DEFAULT_THEME)
|
||||
|
||||
update_check(force=True)
|
||||
|
||||
init_request_processors(app)
|
||||
init_template_filters(app)
|
||||
init_template_globals(app)
|
||||
|
||||
# Importing here allows tests to use sensible names (e.g. api instead of api_bp)
|
||||
from CTFd.admin import admin
|
||||
from CTFd.api import api
|
||||
from CTFd.auth import auth
|
||||
from CTFd.challenges import challenges
|
||||
from CTFd.errors import render_error
|
||||
from CTFd.events import events
|
||||
from CTFd.scoreboard import scoreboard
|
||||
from CTFd.share import social
|
||||
from CTFd.teams import teams
|
||||
from CTFd.users import users
|
||||
from CTFd.views import views
|
||||
|
||||
app.register_blueprint(views)
|
||||
app.register_blueprint(teams)
|
||||
app.register_blueprint(users)
|
||||
app.register_blueprint(challenges)
|
||||
app.register_blueprint(scoreboard)
|
||||
app.register_blueprint(auth)
|
||||
app.register_blueprint(api)
|
||||
app.register_blueprint(events)
|
||||
app.register_blueprint(social)
|
||||
|
||||
app.register_blueprint(admin)
|
||||
|
||||
for code in {403, 404, 500, 502}:
|
||||
app.register_error_handler(code, render_error)
|
||||
|
||||
init_logs(app)
|
||||
init_events(app)
|
||||
init_plugins(app)
|
||||
init_cli(app)
|
||||
|
||||
return app
|
||||
279
CTFd/admin/__init__.py
Normal file
279
CTFd/admin/__init__.py
Normal file
@@ -0,0 +1,279 @@
|
||||
import csv # noqa: I001
|
||||
import datetime
|
||||
import os
|
||||
from io import StringIO
|
||||
|
||||
from flask import Blueprint, abort
|
||||
from flask import current_app as app
|
||||
from flask import (
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
render_template_string,
|
||||
request,
|
||||
send_file,
|
||||
url_for,
|
||||
)
|
||||
|
||||
admin = Blueprint("admin", __name__)
|
||||
|
||||
# isort:imports-firstparty
|
||||
from CTFd.admin import challenges # noqa: F401,I001
|
||||
from CTFd.admin import notifications # noqa: F401,I001
|
||||
from CTFd.admin import pages # noqa: F401,I001
|
||||
from CTFd.admin import scoreboard # noqa: F401,I001
|
||||
from CTFd.admin import statistics # noqa: F401,I001
|
||||
from CTFd.admin import submissions # noqa: F401,I001
|
||||
from CTFd.admin import teams # noqa: F401,I001
|
||||
from CTFd.admin import users # noqa: F401,I001
|
||||
from CTFd.cache import (
|
||||
cache,
|
||||
clear_all_team_sessions,
|
||||
clear_all_user_sessions,
|
||||
clear_challenges,
|
||||
clear_config,
|
||||
clear_pages,
|
||||
clear_standings,
|
||||
)
|
||||
from CTFd.constants.setup import DEFAULTS
|
||||
from CTFd.models import (
|
||||
Awards,
|
||||
Challenges,
|
||||
Configs,
|
||||
Notifications,
|
||||
Pages,
|
||||
Solves,
|
||||
Submissions,
|
||||
Teams,
|
||||
Tracking,
|
||||
Unlocks,
|
||||
Users,
|
||||
db,
|
||||
)
|
||||
from CTFd.utils import config as ctf_config
|
||||
from CTFd.utils import get_app_config, get_config, set_config
|
||||
from CTFd.utils.csv import dump_csv, load_challenges_csv, load_teams_csv, load_users_csv
|
||||
from CTFd.utils.decorators import admins_only
|
||||
from CTFd.utils.exports import background_import_ctf
|
||||
from CTFd.utils.exports import export_ctf as export_ctf_util
|
||||
from CTFd.utils.security.auth import logout_user
|
||||
from CTFd.utils.uploads import delete_file
|
||||
from CTFd.utils.user import is_admin
|
||||
|
||||
|
||||
@admin.route("/admin", methods=["GET"])
|
||||
def view():
|
||||
if is_admin():
|
||||
return redirect(url_for("admin.statistics"))
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@admin.route("/admin/plugins/<plugin>", methods=["GET", "POST"])
|
||||
@admins_only
|
||||
def plugin(plugin):
|
||||
if request.method == "GET":
|
||||
plugins_path = os.path.join(app.root_path, "plugins")
|
||||
|
||||
config_html_plugins = [
|
||||
name
|
||||
for name in os.listdir(plugins_path)
|
||||
if os.path.isfile(os.path.join(plugins_path, name, "config.html"))
|
||||
]
|
||||
|
||||
if plugin in config_html_plugins:
|
||||
config_html = open(
|
||||
os.path.join(app.root_path, "plugins", plugin, "config.html")
|
||||
).read()
|
||||
return render_template_string(config_html)
|
||||
abort(404)
|
||||
elif request.method == "POST":
|
||||
for k, v in request.form.items():
|
||||
if k == "nonce":
|
||||
continue
|
||||
set_config(k, v)
|
||||
with app.app_context():
|
||||
clear_config()
|
||||
return "1"
|
||||
|
||||
|
||||
@admin.route("/admin/import", methods=["GET", "POST"])
|
||||
@admins_only
|
||||
def import_ctf():
|
||||
if request.method == "GET":
|
||||
start_time = cache.get("import_start_time")
|
||||
end_time = cache.get("import_end_time")
|
||||
import_status = cache.get("import_status")
|
||||
import_error = cache.get("import_error")
|
||||
return render_template(
|
||||
"admin/import.html",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
import_status=import_status,
|
||||
import_error=import_error,
|
||||
)
|
||||
elif request.method == "POST":
|
||||
backup = request.files["backup"]
|
||||
background_import_ctf(backup)
|
||||
return redirect(url_for("admin.import_ctf"))
|
||||
|
||||
|
||||
@admin.route("/admin/export", methods=["GET", "POST"])
|
||||
@admins_only
|
||||
def export_ctf():
|
||||
backup = export_ctf_util()
|
||||
ctf_name = ctf_config.ctf_name()
|
||||
day = datetime.datetime.now().strftime("%Y-%m-%d_%T")
|
||||
full_name = "{}.{}.zip".format(ctf_name, day)
|
||||
return send_file(
|
||||
backup, cache_timeout=-1, as_attachment=True, attachment_filename=full_name
|
||||
)
|
||||
|
||||
|
||||
@admin.route("/admin/import/csv", methods=["POST"])
|
||||
@admins_only
|
||||
def import_csv():
|
||||
csv_type = request.form["csv_type"]
|
||||
# Try really hard to load data in properly no matter what nonsense Excel gave you
|
||||
raw = request.files["csv_file"].stream.read()
|
||||
try:
|
||||
csvdata = raw.decode("utf-8-sig")
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
csvdata = raw.decode("cp1252")
|
||||
except UnicodeDecodeError:
|
||||
csvdata = raw.decode("latin-1")
|
||||
csvfile = StringIO(csvdata)
|
||||
|
||||
loaders = {
|
||||
"challenges": load_challenges_csv,
|
||||
"users": load_users_csv,
|
||||
"teams": load_teams_csv,
|
||||
}
|
||||
|
||||
loader = loaders[csv_type]
|
||||
reader = csv.DictReader(csvfile)
|
||||
success = loader(reader)
|
||||
if success is True:
|
||||
return redirect(url_for("admin.config"))
|
||||
else:
|
||||
return jsonify(success), 500
|
||||
|
||||
|
||||
@admin.route("/admin/export/csv")
|
||||
@admins_only
|
||||
def export_csv():
|
||||
table = request.args.get("table")
|
||||
|
||||
output = dump_csv(name=table)
|
||||
|
||||
return send_file(
|
||||
output,
|
||||
as_attachment=True,
|
||||
max_age=-1,
|
||||
download_name="{name}-{table}.csv".format(
|
||||
name=ctf_config.ctf_name(), table=table
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin.route("/admin/config", methods=["GET", "POST"])
|
||||
@admins_only
|
||||
def config():
|
||||
# Clear the config cache so that we don't get stale values
|
||||
clear_config()
|
||||
|
||||
configs = Configs.query.all()
|
||||
configs = {c.key: get_config(c.key) for c in configs}
|
||||
|
||||
# Load in defaults that would normally exist on UI setup
|
||||
for k, v in DEFAULTS.items():
|
||||
if k not in configs:
|
||||
configs[k] = v
|
||||
|
||||
themes = ctf_config.get_themes()
|
||||
|
||||
# Remove current theme but ignore failure
|
||||
try:
|
||||
themes.remove(get_config("ctf_theme"))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
force_html_sanitization = get_app_config("HTML_SANITIZATION")
|
||||
|
||||
return render_template(
|
||||
"admin/config.html",
|
||||
themes=themes,
|
||||
**configs,
|
||||
force_html_sanitization=force_html_sanitization
|
||||
)
|
||||
|
||||
|
||||
@admin.route("/admin/reset", methods=["GET", "POST"])
|
||||
@admins_only
|
||||
def reset():
|
||||
if request.method == "POST":
|
||||
require_setup = False
|
||||
logout = False
|
||||
next_url = url_for("admin.statistics")
|
||||
|
||||
data = request.form
|
||||
|
||||
if data.get("pages"):
|
||||
_pages = Pages.query.all()
|
||||
for p in _pages:
|
||||
for f in p.files:
|
||||
delete_file(file_id=f.id)
|
||||
|
||||
Pages.query.delete()
|
||||
|
||||
if data.get("notifications"):
|
||||
Notifications.query.delete()
|
||||
|
||||
if data.get("challenges"):
|
||||
_challenges = Challenges.query.all()
|
||||
for c in _challenges:
|
||||
for f in c.files:
|
||||
delete_file(file_id=f.id)
|
||||
Challenges.query.delete()
|
||||
|
||||
if data.get("accounts"):
|
||||
Users.query.delete()
|
||||
Teams.query.delete()
|
||||
require_setup = True
|
||||
logout = True
|
||||
|
||||
if data.get("submissions"):
|
||||
Solves.query.delete()
|
||||
Submissions.query.delete()
|
||||
Awards.query.delete()
|
||||
Unlocks.query.delete()
|
||||
Tracking.query.delete()
|
||||
|
||||
if data.get("user_mode") == "users":
|
||||
db.session.query(Users).update({Users.team_id: None})
|
||||
Teams.query.delete()
|
||||
|
||||
clear_all_user_sessions()
|
||||
clear_all_team_sessions()
|
||||
|
||||
if require_setup:
|
||||
set_config("setup", False)
|
||||
cache.clear()
|
||||
logout_user()
|
||||
next_url = url_for("views.setup")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
clear_pages()
|
||||
clear_standings()
|
||||
clear_challenges()
|
||||
clear_config()
|
||||
|
||||
if logout is True:
|
||||
cache.clear()
|
||||
logout_user()
|
||||
|
||||
db.session.close()
|
||||
return redirect(next_url)
|
||||
|
||||
return render_template("admin/reset.html")
|
||||
120
CTFd/admin/challenges.py
Normal file
120
CTFd/admin/challenges.py
Normal file
@@ -0,0 +1,120 @@
|
||||
from flask import abort, render_template, request, url_for
|
||||
|
||||
from CTFd.admin import admin
|
||||
from CTFd.models import Challenges, Flags, Solves
|
||||
from CTFd.plugins.challenges import CHALLENGE_CLASSES, get_chal_class
|
||||
from CTFd.schemas.tags import TagSchema
|
||||
from CTFd.utils.decorators import admins_only
|
||||
from CTFd.utils.security.signing import serialize
|
||||
from CTFd.utils.user import get_current_team, get_current_user
|
||||
|
||||
|
||||
@admin.route("/admin/challenges")
|
||||
@admins_only
|
||||
def challenges_listing():
|
||||
q = request.args.get("q")
|
||||
field = request.args.get("field")
|
||||
filters = []
|
||||
|
||||
if q:
|
||||
# The field exists as an exposed column
|
||||
if Challenges.__mapper__.has_property(field):
|
||||
filters.append(getattr(Challenges, field).like("%{}%".format(q)))
|
||||
|
||||
query = Challenges.query.filter(*filters).order_by(Challenges.id.asc())
|
||||
challenges = query.all()
|
||||
total = query.count()
|
||||
|
||||
return render_template(
|
||||
"admin/challenges/challenges.html",
|
||||
challenges=challenges,
|
||||
total=total,
|
||||
q=q,
|
||||
field=field,
|
||||
)
|
||||
|
||||
|
||||
@admin.route("/admin/challenges/<int:challenge_id>")
|
||||
@admins_only
|
||||
def challenges_detail(challenge_id):
|
||||
challenges = dict(
|
||||
Challenges.query.with_entities(Challenges.id, Challenges.name).all()
|
||||
)
|
||||
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
|
||||
solves = (
|
||||
Solves.query.filter_by(challenge_id=challenge.id)
|
||||
.order_by(Solves.date.asc())
|
||||
.all()
|
||||
)
|
||||
flags = Flags.query.filter_by(challenge_id=challenge.id).all()
|
||||
|
||||
try:
|
||||
challenge_class = get_chal_class(challenge.type)
|
||||
except KeyError:
|
||||
abort(
|
||||
500,
|
||||
f"The underlying challenge type ({challenge.type}) is not installed. This challenge can not be loaded.",
|
||||
)
|
||||
|
||||
update_j2 = render_template(
|
||||
challenge_class.templates["update"].lstrip("/"), challenge=challenge
|
||||
)
|
||||
|
||||
update_script = url_for(
|
||||
"views.static_html", route=challenge_class.scripts["update"].lstrip("/")
|
||||
)
|
||||
return render_template(
|
||||
"admin/challenges/challenge.html",
|
||||
update_template=update_j2,
|
||||
update_script=update_script,
|
||||
challenge=challenge,
|
||||
challenges=challenges,
|
||||
solves=solves,
|
||||
flags=flags,
|
||||
)
|
||||
|
||||
|
||||
@admin.route("/admin/challenges/preview/<int:challenge_id>")
|
||||
@admins_only
|
||||
def challenges_preview(challenge_id):
|
||||
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
|
||||
chal_class = get_chal_class(challenge.type)
|
||||
user = get_current_user()
|
||||
team = get_current_team()
|
||||
|
||||
files = []
|
||||
for f in challenge.files:
|
||||
token = {
|
||||
"user_id": user.id,
|
||||
"team_id": team.id if team else None,
|
||||
"file_id": f.id,
|
||||
}
|
||||
files.append(url_for("views.files", path=f.location, token=serialize(token)))
|
||||
|
||||
tags = [
|
||||
tag["value"] for tag in TagSchema("user", many=True).dump(challenge.tags).data
|
||||
]
|
||||
|
||||
content = render_template(
|
||||
chal_class.templates["view"].lstrip("/"),
|
||||
solves=None,
|
||||
solved_by_me=False,
|
||||
files=files,
|
||||
tags=tags,
|
||||
hints=challenge.hints,
|
||||
max_attempts=challenge.max_attempts,
|
||||
attempts=0,
|
||||
challenge=challenge,
|
||||
rating=None,
|
||||
ratings=None,
|
||||
)
|
||||
return render_template(
|
||||
"admin/challenges/preview.html", content=content, challenge=challenge
|
||||
)
|
||||
|
||||
|
||||
@admin.route("/admin/challenges/new")
|
||||
@admins_only
|
||||
def challenges_new():
|
||||
types = CHALLENGE_CLASSES.keys()
|
||||
return render_template("admin/challenges/new.html", types=types)
|
||||
12
CTFd/admin/notifications.py
Normal file
12
CTFd/admin/notifications.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from flask import render_template
|
||||
|
||||
from CTFd.admin import admin
|
||||
from CTFd.models import Notifications
|
||||
from CTFd.utils.decorators import admins_only
|
||||
|
||||
|
||||
@admin.route("/admin/notifications")
|
||||
@admins_only
|
||||
def notifications():
|
||||
notifs = Notifications.query.order_by(Notifications.id.desc()).all()
|
||||
return render_template("admin/notifications.html", notifications=notifs)
|
||||
49
CTFd/admin/pages.py
Normal file
49
CTFd/admin/pages.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from flask import render_template, request
|
||||
|
||||
from CTFd.admin import admin
|
||||
from CTFd.models import Pages
|
||||
from CTFd.schemas.pages import PageSchema
|
||||
from CTFd.utils import markdown
|
||||
from CTFd.utils.decorators import admins_only
|
||||
|
||||
|
||||
@admin.route("/admin/pages")
|
||||
@admins_only
|
||||
def pages_listing():
|
||||
pages = Pages.query.all()
|
||||
return render_template("admin/pages.html", pages=pages)
|
||||
|
||||
|
||||
@admin.route("/admin/pages/new")
|
||||
@admins_only
|
||||
def pages_new():
|
||||
return render_template("admin/editor.html")
|
||||
|
||||
|
||||
@admin.route("/admin/pages/preview", methods=["POST"])
|
||||
@admins_only
|
||||
def pages_preview():
|
||||
# We only care about content.
|
||||
# Loading other attributes improperly will cause Marshmallow to incorrectly return a dict
|
||||
data = {
|
||||
"content": request.form.get("content"),
|
||||
"format": request.form.get("format"),
|
||||
}
|
||||
schema = PageSchema()
|
||||
page = schema.load(data)
|
||||
return render_template("page.html", content=page.data.html)
|
||||
|
||||
|
||||
@admin.route("/admin/pages/<int:page_id>")
|
||||
@admins_only
|
||||
def pages_detail(page_id):
|
||||
page = Pages.query.filter_by(id=page_id).first_or_404()
|
||||
page_op = request.args.get("operation")
|
||||
|
||||
if request.method == "GET" and page_op == "preview":
|
||||
return render_template("page.html", content=markdown(page.content))
|
||||
|
||||
if request.method == "GET" and page_op == "create":
|
||||
return render_template("admin/editor.html")
|
||||
|
||||
return render_template("admin/editor.html", page=page)
|
||||
16
CTFd/admin/scoreboard.py
Normal file
16
CTFd/admin/scoreboard.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from flask import render_template
|
||||
|
||||
from CTFd.admin import admin
|
||||
from CTFd.utils.config import is_teams_mode
|
||||
from CTFd.utils.decorators import admins_only
|
||||
from CTFd.utils.scores import get_standings, get_user_standings
|
||||
|
||||
|
||||
@admin.route("/admin/scoreboard")
|
||||
@admins_only
|
||||
def scoreboard_listing():
|
||||
standings = get_standings(admin=True)
|
||||
user_standings = get_user_standings(admin=True) if is_teams_mode() else None
|
||||
return render_template(
|
||||
"admin/scoreboard.html", standings=standings, user_standings=user_standings
|
||||
)
|
||||
217
CTFd/admin/statistics.py
Normal file
217
CTFd/admin/statistics.py
Normal file
@@ -0,0 +1,217 @@
|
||||
from flask import render_template
|
||||
|
||||
from CTFd.admin import admin
|
||||
from CTFd.models import Challenges, Fails, Solves, Teams, Tracking, Users, db
|
||||
from CTFd.utils.config import get_config
|
||||
from CTFd.utils.decorators import admins_only
|
||||
from CTFd.utils.modes import get_model
|
||||
from CTFd.utils.scores import get_standings
|
||||
from CTFd.utils.updates import update_check
|
||||
|
||||
|
||||
@admin.route("/admin/statistics", methods=["GET"])
|
||||
@admins_only
|
||||
def statistics():
|
||||
update_check()
|
||||
|
||||
Model = get_model()
|
||||
|
||||
teams_registered = Teams.query.count()
|
||||
users_registered = Users.query.count()
|
||||
|
||||
wrong_count = (
|
||||
Fails.query.join(Model, Fails.account_id == Model.id)
|
||||
.filter(Model.banned == False, Model.hidden == False)
|
||||
.count()
|
||||
)
|
||||
|
||||
solve_count = (
|
||||
Solves.query.join(Model, Solves.account_id == Model.id)
|
||||
.filter(Model.banned == False, Model.hidden == False)
|
||||
.count()
|
||||
)
|
||||
|
||||
challenge_count = Challenges.query.count()
|
||||
|
||||
total_points = (
|
||||
Challenges.query.with_entities(db.func.sum(Challenges.value).label("sum"))
|
||||
.filter_by(state="visible")
|
||||
.first()
|
||||
.sum
|
||||
) or 0
|
||||
|
||||
ip_count = Tracking.query.with_entities(Tracking.ip).distinct().count()
|
||||
|
||||
solves_sub = (
|
||||
db.session.query(
|
||||
Solves.challenge_id, db.func.count(Solves.challenge_id).label("solves_cnt")
|
||||
)
|
||||
.join(Model, Solves.account_id == Model.id)
|
||||
.filter(Model.banned == False, Model.hidden == False)
|
||||
.group_by(Solves.challenge_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
solves = (
|
||||
db.session.query(
|
||||
solves_sub.columns.challenge_id,
|
||||
solves_sub.columns.solves_cnt,
|
||||
Challenges.name,
|
||||
)
|
||||
.join(Challenges, solves_sub.columns.challenge_id == Challenges.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
solve_data = {}
|
||||
for _chal, count, name in solves:
|
||||
solve_data[name] = count
|
||||
|
||||
most_solved = None
|
||||
least_solved = None
|
||||
if len(solve_data):
|
||||
most_solved = max(solve_data, key=solve_data.get)
|
||||
least_solved = min(solve_data, key=solve_data.get)
|
||||
|
||||
account_scores = get_standings(count=100, admin=True)
|
||||
|
||||
# Get all challenges ordered by category and value
|
||||
all_challenges = (
|
||||
Challenges.query.filter(Challenges.state == "visible")
|
||||
.order_by(Challenges.value.asc(), Challenges.category)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Get solve matrix data for top 100 accounts
|
||||
top_account_ids = [account.account_id for account in account_scores]
|
||||
|
||||
if top_account_ids:
|
||||
solve_matrix_data = (
|
||||
db.session.query(
|
||||
Solves.account_id,
|
||||
Solves.challenge_id,
|
||||
Challenges.name.label("challenge_name"),
|
||||
)
|
||||
.join(Challenges, Challenges.id == Solves.challenge_id)
|
||||
.join(Model, Model.id == Solves.account_id)
|
||||
.filter(
|
||||
Solves.account_id.in_(top_account_ids),
|
||||
Model.banned == False,
|
||||
Model.hidden == False,
|
||||
Challenges.state == "visible",
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Get attempt matrix data (fails) for top 100 accounts
|
||||
attempt_matrix_data = (
|
||||
db.session.query(
|
||||
Fails.account_id,
|
||||
Fails.challenge_id,
|
||||
Challenges.name.label("challenge_name"),
|
||||
)
|
||||
.join(Challenges, Challenges.id == Fails.challenge_id)
|
||||
.join(Model, Model.id == Fails.account_id)
|
||||
.filter(
|
||||
Fails.account_id.in_(top_account_ids),
|
||||
Model.banned == False,
|
||||
Model.hidden == False,
|
||||
Challenges.state == "visible",
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Get challenge opens matrix data for top 100 accounts
|
||||
# Need to handle mapping from user_id (in Tracking) to account_id (user or team)
|
||||
if get_config("user_mode") == "teams":
|
||||
# In teams mode, map user_id to team_id (account_id)
|
||||
opens_matrix_data = (
|
||||
db.session.query(
|
||||
Teams.id.label("account_id"),
|
||||
Tracking.target.label("challenge_id"),
|
||||
)
|
||||
.join(Users, Users.id == Tracking.user_id)
|
||||
.join(Teams, Teams.id == Users.team_id)
|
||||
.join(Challenges, Challenges.id == Tracking.target)
|
||||
.filter(
|
||||
Teams.id.in_(top_account_ids),
|
||||
Users.banned == False,
|
||||
Users.hidden == False,
|
||||
Teams.banned == False,
|
||||
Teams.hidden == False,
|
||||
Challenges.state == "visible",
|
||||
Tracking.target.isnot(None), # Ensure target is not null
|
||||
Tracking.type == "challenges.open", # Only track challenge opens
|
||||
)
|
||||
.distinct() # Remove duplicates if user opened same challenge multiple times
|
||||
.all()
|
||||
)
|
||||
else:
|
||||
# In users mode, user_id maps directly to account_id
|
||||
opens_matrix_data = (
|
||||
db.session.query(
|
||||
Tracking.user_id.label("account_id"),
|
||||
Tracking.target.label("challenge_id"),
|
||||
)
|
||||
.join(Users, Users.id == Tracking.user_id)
|
||||
.join(Challenges, Challenges.id == Tracking.target)
|
||||
.filter(
|
||||
Tracking.user_id.in_(top_account_ids),
|
||||
Users.banned == False,
|
||||
Users.hidden == False,
|
||||
Challenges.state == "visible",
|
||||
Tracking.target.isnot(None), # Ensure target is not null
|
||||
Tracking.type == "challenges.open", # Only track challenge opens
|
||||
)
|
||||
.distinct() # Remove duplicates if user opened same challenge multiple times
|
||||
.all()
|
||||
)
|
||||
|
||||
# Build matrix data structure
|
||||
account_solves = {}
|
||||
for account in account_scores:
|
||||
account_solves[account.account_id] = {
|
||||
"name": account.name,
|
||||
"score": account.score,
|
||||
"solved_challenges": set(),
|
||||
"attempted_challenges": set(),
|
||||
"opened_challenges": set(),
|
||||
}
|
||||
|
||||
for solve in solve_matrix_data:
|
||||
if solve.account_id in account_solves:
|
||||
account_solves[solve.account_id]["solved_challenges"].add(
|
||||
solve.challenge_id
|
||||
)
|
||||
|
||||
for attempt in attempt_matrix_data:
|
||||
if attempt.account_id in account_solves:
|
||||
account_solves[attempt.account_id]["attempted_challenges"].add(
|
||||
attempt.challenge_id
|
||||
)
|
||||
|
||||
for opens in opens_matrix_data:
|
||||
if opens.account_id in account_solves:
|
||||
account_solves[opens.account_id]["opened_challenges"].add(
|
||||
opens.challenge_id
|
||||
)
|
||||
else:
|
||||
account_solves = {}
|
||||
|
||||
db.session.close()
|
||||
|
||||
return render_template(
|
||||
"admin/statistics.html",
|
||||
user_count=users_registered,
|
||||
team_count=teams_registered,
|
||||
ip_count=ip_count,
|
||||
wrong_count=wrong_count,
|
||||
solve_count=solve_count,
|
||||
challenge_count=challenge_count,
|
||||
total_points=total_points,
|
||||
solve_data=solve_data,
|
||||
most_solved=most_solved,
|
||||
least_solved=least_solved,
|
||||
top_users=account_scores,
|
||||
all_challenges=all_challenges,
|
||||
account_solves=account_solves,
|
||||
)
|
||||
65
CTFd/admin/submissions.py
Normal file
65
CTFd/admin/submissions.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from flask import render_template, request, url_for
|
||||
|
||||
from CTFd.admin import admin
|
||||
from CTFd.models import Challenges, Submissions
|
||||
from CTFd.utils.decorators import admins_only
|
||||
from CTFd.utils.helpers.models import build_model_filters
|
||||
from CTFd.utils.modes import get_model
|
||||
|
||||
|
||||
@admin.route("/admin/submissions", defaults={"submission_type": None})
|
||||
@admin.route("/admin/submissions/<submission_type>")
|
||||
@admins_only
|
||||
def submissions_listing(submission_type):
|
||||
filters_by = {}
|
||||
if submission_type:
|
||||
filters_by["type"] = submission_type
|
||||
filters = []
|
||||
|
||||
q = request.args.get("q")
|
||||
field = request.args.get("field")
|
||||
page = abs(request.args.get("page", 1, type=int))
|
||||
|
||||
filters = build_model_filters(
|
||||
model=Submissions,
|
||||
query=q,
|
||||
field=field,
|
||||
extra_columns={
|
||||
"challenge_name": Challenges.name,
|
||||
"account_id": Submissions.account_id,
|
||||
},
|
||||
)
|
||||
|
||||
Model = get_model()
|
||||
|
||||
submissions = (
|
||||
Submissions.query.filter_by(**filters_by)
|
||||
.filter(*filters)
|
||||
.join(Challenges)
|
||||
.join(Model)
|
||||
.order_by(Submissions.date.desc())
|
||||
.paginate(page=page, per_page=50, error_out=False)
|
||||
)
|
||||
|
||||
args = dict(request.args)
|
||||
args.pop("page", 1)
|
||||
|
||||
return render_template(
|
||||
"admin/submissions.html",
|
||||
submissions=submissions,
|
||||
prev_page=url_for(
|
||||
request.endpoint,
|
||||
submission_type=submission_type,
|
||||
page=submissions.prev_num,
|
||||
**args
|
||||
),
|
||||
next_page=url_for(
|
||||
request.endpoint,
|
||||
submission_type=submission_type,
|
||||
page=submissions.next_num,
|
||||
**args
|
||||
),
|
||||
type=submission_type,
|
||||
q=q,
|
||||
field=field,
|
||||
)
|
||||
86
CTFd/admin/teams.py
Normal file
86
CTFd/admin/teams.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from flask import render_template, request, url_for
|
||||
from sqlalchemy.sql import not_
|
||||
|
||||
from CTFd.admin import admin
|
||||
from CTFd.models import Challenges, Teams, Tracking
|
||||
from CTFd.utils.decorators import admins_only
|
||||
|
||||
|
||||
@admin.route("/admin/teams")
|
||||
@admins_only
|
||||
def teams_listing():
|
||||
q = request.args.get("q")
|
||||
field = request.args.get("field")
|
||||
page = abs(request.args.get("page", 1, type=int))
|
||||
filters = []
|
||||
|
||||
if q:
|
||||
# The field exists as an exposed column
|
||||
if Teams.__mapper__.has_property(field):
|
||||
filters.append(getattr(Teams, field).like("%{}%".format(q)))
|
||||
|
||||
teams = (
|
||||
Teams.query.filter(*filters)
|
||||
.order_by(Teams.id.asc())
|
||||
.paginate(page=page, per_page=50, error_out=False)
|
||||
)
|
||||
|
||||
args = dict(request.args)
|
||||
args.pop("page", 1)
|
||||
|
||||
return render_template(
|
||||
"admin/teams/teams.html",
|
||||
teams=teams,
|
||||
prev_page=url_for(request.endpoint, page=teams.prev_num, **args),
|
||||
next_page=url_for(request.endpoint, page=teams.next_num, **args),
|
||||
q=q,
|
||||
field=field,
|
||||
)
|
||||
|
||||
|
||||
@admin.route("/admin/teams/new")
|
||||
@admins_only
|
||||
def teams_new():
|
||||
return render_template("admin/teams/new.html")
|
||||
|
||||
|
||||
@admin.route("/admin/teams/<int:team_id>")
|
||||
@admins_only
|
||||
def teams_detail(team_id):
|
||||
team = Teams.query.filter_by(id=team_id).first_or_404()
|
||||
|
||||
# Get members
|
||||
members = team.members
|
||||
member_ids = [member.id for member in members]
|
||||
|
||||
# Get Solves for all members
|
||||
solves = team.get_solves(admin=True)
|
||||
fails = team.get_fails(admin=True)
|
||||
awards = team.get_awards(admin=True)
|
||||
score = team.get_score(admin=True)
|
||||
place = team.get_place(admin=True)
|
||||
|
||||
# Get missing Challenges for all members
|
||||
# TODO: How do you mark a missing challenge for a team?
|
||||
solve_ids = [s.challenge_id for s in solves]
|
||||
missing = Challenges.query.filter(not_(Challenges.id.in_(solve_ids))).all()
|
||||
|
||||
# Get addresses for all members
|
||||
addrs = (
|
||||
Tracking.query.filter(Tracking.user_id.in_(member_ids))
|
||||
.order_by(Tracking.date.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
return render_template(
|
||||
"admin/teams/team.html",
|
||||
team=team,
|
||||
members=members,
|
||||
score=score,
|
||||
place=place,
|
||||
solves=solves,
|
||||
fails=fails,
|
||||
missing=missing,
|
||||
awards=awards,
|
||||
addrs=addrs,
|
||||
)
|
||||
109
CTFd/admin/users.py
Normal file
109
CTFd/admin/users.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from flask import render_template, request, url_for
|
||||
from sqlalchemy.sql import not_
|
||||
|
||||
from CTFd.admin import admin
|
||||
from CTFd.models import Challenges, Tracking, Users
|
||||
from CTFd.utils import get_config
|
||||
from CTFd.utils.decorators import admins_only
|
||||
from CTFd.utils.modes import TEAMS_MODE
|
||||
|
||||
|
||||
@admin.route("/admin/users")
|
||||
@admins_only
|
||||
def users_listing():
|
||||
q = request.args.get("q")
|
||||
field = request.args.get("field")
|
||||
page = abs(request.args.get("page", 1, type=int))
|
||||
filters = []
|
||||
users = []
|
||||
|
||||
if q:
|
||||
# The field exists as an exposed column
|
||||
if Users.__mapper__.has_property(field):
|
||||
filters.append(getattr(Users, field).like("%{}%".format(q)))
|
||||
|
||||
if q and field == "ip":
|
||||
users = (
|
||||
Users.query.join(Tracking, Users.id == Tracking.user_id)
|
||||
.filter(Tracking.ip.like("%{}%".format(q)))
|
||||
.order_by(Users.id.asc())
|
||||
.paginate(page=page, per_page=50, error_out=False)
|
||||
)
|
||||
else:
|
||||
users = (
|
||||
Users.query.filter(*filters)
|
||||
.order_by(Users.id.asc())
|
||||
.paginate(page=page, per_page=50, error_out=False)
|
||||
)
|
||||
|
||||
args = dict(request.args)
|
||||
args.pop("page", 1)
|
||||
|
||||
return render_template(
|
||||
"admin/users/users.html",
|
||||
users=users,
|
||||
prev_page=url_for(request.endpoint, page=users.prev_num, **args),
|
||||
next_page=url_for(request.endpoint, page=users.next_num, **args),
|
||||
q=q,
|
||||
field=field,
|
||||
)
|
||||
|
||||
|
||||
@admin.route("/admin/users/new")
|
||||
@admins_only
|
||||
def users_new():
|
||||
return render_template("admin/users/new.html")
|
||||
|
||||
|
||||
@admin.route("/admin/users/<int:user_id>")
|
||||
@admins_only
|
||||
def users_detail(user_id):
|
||||
# Get user object
|
||||
user = Users.query.filter_by(id=user_id).first_or_404()
|
||||
|
||||
# Get the user's solves
|
||||
solves = user.get_solves(admin=True)
|
||||
|
||||
# Get challenges that the user is missing
|
||||
if get_config("user_mode") == TEAMS_MODE:
|
||||
if user.team:
|
||||
all_solves = user.team.get_solves(admin=True)
|
||||
else:
|
||||
all_solves = user.get_solves(admin=True)
|
||||
else:
|
||||
all_solves = user.get_solves(admin=True)
|
||||
|
||||
solve_ids = [s.challenge_id for s in all_solves]
|
||||
missing = Challenges.query.filter(not_(Challenges.id.in_(solve_ids))).all()
|
||||
|
||||
# Get IP addresses that the User has used
|
||||
addrs = (
|
||||
Tracking.query.filter_by(user_id=user_id).order_by(Tracking.date.desc()).all()
|
||||
)
|
||||
|
||||
# Get Fails
|
||||
fails = user.get_fails(admin=True)
|
||||
|
||||
# Get Awards
|
||||
awards = user.get_awards(admin=True)
|
||||
|
||||
# Check if the user has an account (team or user)
|
||||
# so that we don't throw an error if they dont
|
||||
if user.account:
|
||||
score = user.account.get_score(admin=True)
|
||||
place = user.account.get_place(admin=True)
|
||||
else:
|
||||
score = None
|
||||
place = None
|
||||
|
||||
return render_template(
|
||||
"admin/users/user.html",
|
||||
solves=solves,
|
||||
user=user,
|
||||
addrs=addrs,
|
||||
score=score,
|
||||
missing=missing,
|
||||
place=place,
|
||||
fails=fails,
|
||||
awards=awards,
|
||||
)
|
||||
75
CTFd/api/__init__.py
Normal file
75
CTFd/api/__init__.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from flask import Blueprint, current_app
|
||||
from flask_restx import Api
|
||||
|
||||
from CTFd.api.v1.awards import awards_namespace
|
||||
from CTFd.api.v1.brackets import brackets_namespace
|
||||
from CTFd.api.v1.challenges import challenges_namespace
|
||||
from CTFd.api.v1.comments import comments_namespace
|
||||
from CTFd.api.v1.config import configs_namespace
|
||||
from CTFd.api.v1.exports import exports_namespace
|
||||
from CTFd.api.v1.files import files_namespace
|
||||
from CTFd.api.v1.flags import flags_namespace
|
||||
from CTFd.api.v1.hints import hints_namespace
|
||||
from CTFd.api.v1.notifications import notifications_namespace
|
||||
from CTFd.api.v1.pages import pages_namespace
|
||||
from CTFd.api.v1.schemas import (
|
||||
APIDetailedSuccessResponse,
|
||||
APISimpleErrorResponse,
|
||||
APISimpleSuccessResponse,
|
||||
)
|
||||
from CTFd.api.v1.scoreboard import scoreboard_namespace
|
||||
from CTFd.api.v1.shares import shares_namespace
|
||||
from CTFd.api.v1.solutions import solutions_namespace
|
||||
from CTFd.api.v1.statistics import statistics_namespace
|
||||
from CTFd.api.v1.submissions import submissions_namespace
|
||||
from CTFd.api.v1.tags import tags_namespace
|
||||
from CTFd.api.v1.teams import teams_namespace
|
||||
from CTFd.api.v1.tokens import tokens_namespace
|
||||
from CTFd.api.v1.topics import topics_namespace
|
||||
from CTFd.api.v1.unlocks import unlocks_namespace
|
||||
from CTFd.api.v1.users import users_namespace
|
||||
|
||||
api = Blueprint("api", __name__, url_prefix="/api/v1")
|
||||
CTFd_API_v1 = Api(
|
||||
api,
|
||||
version="v1",
|
||||
doc=current_app.config.get("SWAGGER_UI_ENDPOINT"),
|
||||
authorizations={
|
||||
"AccessToken": {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "Authorization",
|
||||
"description": "Generate access token in the settings page of your user account.",
|
||||
},
|
||||
},
|
||||
security=["AccessToken"],
|
||||
)
|
||||
|
||||
CTFd_API_v1.schema_model("APISimpleErrorResponse", APISimpleErrorResponse.schema())
|
||||
CTFd_API_v1.schema_model(
|
||||
"APIDetailedSuccessResponse", APIDetailedSuccessResponse.schema()
|
||||
)
|
||||
CTFd_API_v1.schema_model("APISimpleSuccessResponse", APISimpleSuccessResponse.schema())
|
||||
|
||||
CTFd_API_v1.add_namespace(challenges_namespace, "/challenges")
|
||||
CTFd_API_v1.add_namespace(tags_namespace, "/tags")
|
||||
CTFd_API_v1.add_namespace(topics_namespace, "/topics")
|
||||
CTFd_API_v1.add_namespace(awards_namespace, "/awards")
|
||||
CTFd_API_v1.add_namespace(hints_namespace, "/hints")
|
||||
CTFd_API_v1.add_namespace(flags_namespace, "/flags")
|
||||
CTFd_API_v1.add_namespace(submissions_namespace, "/submissions")
|
||||
CTFd_API_v1.add_namespace(scoreboard_namespace, "/scoreboard")
|
||||
CTFd_API_v1.add_namespace(teams_namespace, "/teams")
|
||||
CTFd_API_v1.add_namespace(users_namespace, "/users")
|
||||
CTFd_API_v1.add_namespace(statistics_namespace, "/statistics")
|
||||
CTFd_API_v1.add_namespace(files_namespace, "/files")
|
||||
CTFd_API_v1.add_namespace(notifications_namespace, "/notifications")
|
||||
CTFd_API_v1.add_namespace(configs_namespace, "/configs")
|
||||
CTFd_API_v1.add_namespace(pages_namespace, "/pages")
|
||||
CTFd_API_v1.add_namespace(unlocks_namespace, "/unlocks")
|
||||
CTFd_API_v1.add_namespace(tokens_namespace, "/tokens")
|
||||
CTFd_API_v1.add_namespace(comments_namespace, "/comments")
|
||||
CTFd_API_v1.add_namespace(shares_namespace, "/shares")
|
||||
CTFd_API_v1.add_namespace(brackets_namespace, "/brackets")
|
||||
CTFd_API_v1.add_namespace(exports_namespace, "/exports")
|
||||
CTFd_API_v1.add_namespace(solutions_namespace, "/solutions")
|
||||
0
CTFd/api/v1/__init__.py
Normal file
0
CTFd/api/v1/__init__.py
Normal file
177
CTFd/api/v1/awards.py
Normal file
177
CTFd/api/v1/awards.py
Normal file
@@ -0,0 +1,177 @@
|
||||
from typing import List
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||
from CTFd.cache import clear_standings
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import Awards, Users, db
|
||||
from CTFd.schemas.awards import AwardSchema
|
||||
from CTFd.utils.config import is_teams_mode
|
||||
from CTFd.utils.decorators import admins_only
|
||||
from CTFd.utils.helpers.models import build_model_filters
|
||||
|
||||
awards_namespace = Namespace("awards", description="Endpoint to retrieve Awards")
|
||||
|
||||
AwardModel = sqlalchemy_to_pydantic(Awards)
|
||||
|
||||
|
||||
class AwardDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: AwardModel
|
||||
|
||||
|
||||
class AwardListSuccessResponse(APIListSuccessResponse):
|
||||
data: List[AwardModel]
|
||||
|
||||
|
||||
awards_namespace.schema_model(
|
||||
"AwardDetailedSuccessResponse", AwardDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
awards_namespace.schema_model(
|
||||
"AwardListSuccessResponse", AwardListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@awards_namespace.route("")
|
||||
class AwardList(Resource):
|
||||
@admins_only
|
||||
@awards_namespace.doc(
|
||||
description="Endpoint to list Award objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "AwardListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"user_id": (int, None),
|
||||
"team_id": (int, None),
|
||||
"type": (str, None),
|
||||
"value": (int, None),
|
||||
"category": (int, None),
|
||||
"icon": (int, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum(
|
||||
"AwardFields",
|
||||
{
|
||||
"name": "name",
|
||||
"description": "description",
|
||||
"category": "category",
|
||||
"icon": "icon",
|
||||
},
|
||||
),
|
||||
None,
|
||||
),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Awards, query=q, field=field)
|
||||
|
||||
awards = Awards.query.filter_by(**query_args).filter(*filters).all()
|
||||
schema = AwardSchema(many=True)
|
||||
response = schema.dump(awards)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@awards_namespace.doc(
|
||||
description="Endpoint to create an Award object",
|
||||
responses={
|
||||
200: ("Success", "AwardListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
|
||||
# Force a team_id if in team mode and unspecified
|
||||
if is_teams_mode():
|
||||
team_id = req.get("team_id")
|
||||
if team_id is None:
|
||||
user = Users.query.filter_by(id=req["user_id"]).first_or_404()
|
||||
if user.team_id is None:
|
||||
return (
|
||||
{
|
||||
"success": False,
|
||||
"errors": {
|
||||
"team_id": [
|
||||
"User doesn't have a team to associate award with"
|
||||
]
|
||||
},
|
||||
},
|
||||
400,
|
||||
)
|
||||
req["team_id"] = user.team_id
|
||||
|
||||
schema = AwardSchema()
|
||||
|
||||
response = schema.load(req, session=db.session)
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
db.session.add(response.data)
|
||||
db.session.commit()
|
||||
|
||||
response = schema.dump(response.data)
|
||||
db.session.close()
|
||||
|
||||
# Delete standings cache because awards can change scores
|
||||
clear_standings()
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
|
||||
@awards_namespace.route("/<award_id>")
|
||||
@awards_namespace.param("award_id", "An Award ID")
|
||||
class Award(Resource):
|
||||
@admins_only
|
||||
@awards_namespace.doc(
|
||||
description="Endpoint to get a specific Award object",
|
||||
responses={
|
||||
200: ("Success", "AwardDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, award_id):
|
||||
award = Awards.query.filter_by(id=award_id).first_or_404()
|
||||
response = AwardSchema().dump(award)
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@awards_namespace.doc(
|
||||
description="Endpoint to delete an Award object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self, award_id):
|
||||
award = Awards.query.filter_by(id=award_id).first_or_404()
|
||||
db.session.delete(award)
|
||||
db.session.commit()
|
||||
db.session.close()
|
||||
|
||||
# Delete standings cache because awards can change scores
|
||||
clear_standings()
|
||||
|
||||
return {"success": True}
|
||||
89
CTFd/api/v1/brackets.py
Normal file
89
CTFd/api/v1/brackets.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from flask import request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import Brackets, db
|
||||
from CTFd.schemas.brackets import BracketSchema
|
||||
from CTFd.utils.decorators import admins_only
|
||||
from CTFd.utils.helpers.models import build_model_filters
|
||||
|
||||
brackets_namespace = Namespace("brackets", description="Endpoint to retrieve Brackets")
|
||||
|
||||
|
||||
@brackets_namespace.route("")
|
||||
class BracketList(Resource):
|
||||
@validate_args(
|
||||
{
|
||||
"name": (str, None),
|
||||
"description": (str, None),
|
||||
"type": (str, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum(
|
||||
"BracketFields",
|
||||
{"name": "name", "description": "description", "type": "type"},
|
||||
),
|
||||
None,
|
||||
),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Brackets, query=q, field=field)
|
||||
|
||||
brackets = Brackets.query.filter_by(**query_args).filter(*filters).all()
|
||||
schema = BracketSchema(many=True)
|
||||
response = schema.dump(brackets)
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
schema = BracketSchema()
|
||||
response = schema.load(req, session=db.session)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
db.session.add(response.data)
|
||||
db.session.commit()
|
||||
|
||||
response = schema.dump(response.data)
|
||||
db.session.close()
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
|
||||
@brackets_namespace.route("/<int:bracket_id>")
|
||||
class Bracket(Resource):
|
||||
@admins_only
|
||||
def patch(self, bracket_id):
|
||||
bracket = Brackets.query.filter_by(id=bracket_id).first_or_404()
|
||||
schema = BracketSchema()
|
||||
|
||||
req = request.get_json()
|
||||
|
||||
response = schema.load(req, session=db.session, instance=bracket)
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
db.session.commit()
|
||||
|
||||
response = schema.dump(response.data)
|
||||
db.session.close()
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
def delete(self, bracket_id):
|
||||
bracket = Brackets.query.filter_by(id=bracket_id).first_or_404()
|
||||
db.session.delete(bracket)
|
||||
db.session.commit()
|
||||
db.session.close()
|
||||
|
||||
return {"success": True}
|
||||
1275
CTFd/api/v1/challenges.py
Normal file
1275
CTFd/api/v1/challenges.py
Normal file
File diff suppressed because it is too large
Load Diff
159
CTFd/api/v1/comments.py
Normal file
159
CTFd/api/v1/comments.py
Normal file
@@ -0,0 +1,159 @@
|
||||
from typing import List
|
||||
|
||||
from flask import request, session
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import (
|
||||
ChallengeComments,
|
||||
Comments,
|
||||
PageComments,
|
||||
TeamComments,
|
||||
UserComments,
|
||||
db,
|
||||
)
|
||||
from CTFd.schemas.comments import CommentSchema
|
||||
from CTFd.utils.decorators import admins_only
|
||||
from CTFd.utils.helpers.models import build_model_filters
|
||||
|
||||
comments_namespace = Namespace("comments", description="Endpoint to retrieve Comments")
|
||||
|
||||
|
||||
CommentModel = sqlalchemy_to_pydantic(Comments)
|
||||
|
||||
|
||||
class CommentDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: CommentModel
|
||||
|
||||
|
||||
class CommentListSuccessResponse(APIListSuccessResponse):
|
||||
data: List[CommentModel]
|
||||
|
||||
|
||||
comments_namespace.schema_model(
|
||||
"CommentDetailedSuccessResponse", CommentDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
comments_namespace.schema_model(
|
||||
"CommentListSuccessResponse", CommentListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
def get_comment_model(data):
|
||||
model = Comments
|
||||
if "challenge_id" in data:
|
||||
model = ChallengeComments
|
||||
elif "user_id" in data:
|
||||
model = UserComments
|
||||
elif "team_id" in data:
|
||||
model = TeamComments
|
||||
elif "page_id" in data:
|
||||
model = PageComments
|
||||
else:
|
||||
model = Comments
|
||||
return model
|
||||
|
||||
|
||||
@comments_namespace.route("")
|
||||
class CommentList(Resource):
|
||||
@admins_only
|
||||
@comments_namespace.doc(
|
||||
description="Endpoint to list Comment objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "CommentListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"challenge_id": (int, None),
|
||||
"user_id": (int, None),
|
||||
"team_id": (int, None),
|
||||
"page_id": (int, None),
|
||||
"q": (str, None),
|
||||
"field": (RawEnum("CommentFields", {"content": "content"}), None),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
CommentModel = get_comment_model(data=query_args)
|
||||
filters = build_model_filters(model=CommentModel, query=q, field=field)
|
||||
|
||||
comments = (
|
||||
CommentModel.query.filter_by(**query_args)
|
||||
.filter(*filters)
|
||||
.order_by(CommentModel.id.desc())
|
||||
.paginate(max_per_page=100, error_out=False)
|
||||
)
|
||||
schema = CommentSchema(many=True)
|
||||
response = schema.dump(comments.items)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {
|
||||
"meta": {
|
||||
"pagination": {
|
||||
"page": comments.page,
|
||||
"next": comments.next_num,
|
||||
"prev": comments.prev_num,
|
||||
"pages": comments.pages,
|
||||
"per_page": comments.per_page,
|
||||
"total": comments.total,
|
||||
}
|
||||
},
|
||||
"success": True,
|
||||
"data": response.data,
|
||||
}
|
||||
|
||||
@admins_only
|
||||
@comments_namespace.doc(
|
||||
description="Endpoint to create a Comment object",
|
||||
responses={
|
||||
200: ("Success", "CommentDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
# Always force author IDs to be the actual user
|
||||
req["author_id"] = session["id"]
|
||||
CommentModel = get_comment_model(data=req)
|
||||
|
||||
m = CommentModel(**req)
|
||||
db.session.add(m)
|
||||
db.session.commit()
|
||||
|
||||
schema = CommentSchema()
|
||||
|
||||
response = schema.dump(m)
|
||||
db.session.close()
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
|
||||
@comments_namespace.route("/<comment_id>")
|
||||
class Comment(Resource):
|
||||
@admins_only
|
||||
@comments_namespace.doc(
|
||||
description="Endpoint to delete a specific Comment object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self, comment_id):
|
||||
comment = Comments.query.filter_by(id=comment_id).first_or_404()
|
||||
db.session.delete(comment)
|
||||
db.session.commit()
|
||||
db.session.close()
|
||||
|
||||
return {"success": True}
|
||||
286
CTFd/api/v1/config.py
Normal file
286
CTFd/api/v1/config.py
Normal file
@@ -0,0 +1,286 @@
|
||||
from typing import List
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||
from CTFd.cache import clear_challenges, clear_config, clear_standings
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import Configs, Fields, db
|
||||
from CTFd.schemas.config import ConfigSchema
|
||||
from CTFd.schemas.fields import FieldSchema
|
||||
from CTFd.utils import set_config
|
||||
from CTFd.utils.decorators import admins_only
|
||||
from CTFd.utils.helpers.models import build_model_filters
|
||||
|
||||
configs_namespace = Namespace("configs", description="Endpoint to retrieve Configs")
|
||||
|
||||
ConfigModel = sqlalchemy_to_pydantic(Configs)
|
||||
|
||||
|
||||
class ConfigDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: ConfigModel
|
||||
|
||||
|
||||
class ConfigListSuccessResponse(APIListSuccessResponse):
|
||||
data: List[ConfigModel]
|
||||
|
||||
|
||||
configs_namespace.schema_model(
|
||||
"ConfigDetailedSuccessResponse", ConfigDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
configs_namespace.schema_model(
|
||||
"ConfigListSuccessResponse", ConfigListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@configs_namespace.route("")
|
||||
class ConfigList(Resource):
|
||||
@admins_only
|
||||
@configs_namespace.doc(
|
||||
description="Endpoint to get Config objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "ConfigListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"key": (str, None),
|
||||
"value": (str, None),
|
||||
"q": (str, None),
|
||||
"field": (RawEnum("ConfigFields", {"key": "key", "value": "value"}), None),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Configs, query=q, field=field)
|
||||
|
||||
configs = Configs.query.filter_by(**query_args).filter(*filters).all()
|
||||
schema = ConfigSchema(many=True)
|
||||
response = schema.dump(configs)
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@configs_namespace.doc(
|
||||
description="Endpoint to get create a Config object",
|
||||
responses={
|
||||
200: ("Success", "ConfigDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
schema = ConfigSchema()
|
||||
response = schema.load(req)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
db.session.add(response.data)
|
||||
db.session.commit()
|
||||
|
||||
response = schema.dump(response.data)
|
||||
db.session.close()
|
||||
|
||||
clear_config()
|
||||
clear_standings()
|
||||
clear_challenges()
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@configs_namespace.doc(
|
||||
description="Endpoint to get patch Config objects in bulk",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def patch(self):
|
||||
req = request.get_json()
|
||||
schema = ConfigSchema()
|
||||
|
||||
for key, value in req.items():
|
||||
response = schema.load({"key": key, "value": value})
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
set_config(key=key, value=value)
|
||||
|
||||
clear_config()
|
||||
clear_standings()
|
||||
clear_challenges()
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@configs_namespace.route("/<config_key>")
|
||||
class Config(Resource):
|
||||
@admins_only
|
||||
@configs_namespace.doc(
|
||||
description="Endpoint to get a specific Config object",
|
||||
responses={
|
||||
200: ("Success", "ConfigDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, config_key):
|
||||
config = Configs.query.filter_by(key=config_key).first_or_404()
|
||||
schema = ConfigSchema()
|
||||
response = schema.dump(config)
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@configs_namespace.doc(
|
||||
description="Endpoint to edit a specific Config object",
|
||||
responses={
|
||||
200: ("Success", "ConfigDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def patch(self, config_key):
|
||||
config = Configs.query.filter_by(key=config_key).first()
|
||||
data = request.get_json()
|
||||
if config:
|
||||
schema = ConfigSchema(instance=config, partial=True)
|
||||
response = schema.load(data)
|
||||
else:
|
||||
schema = ConfigSchema()
|
||||
data["key"] = config_key
|
||||
response = schema.load(data)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
db.session.add(response.data)
|
||||
db.session.commit()
|
||||
|
||||
response = schema.dump(response.data)
|
||||
db.session.close()
|
||||
|
||||
clear_config()
|
||||
clear_standings()
|
||||
clear_challenges()
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@configs_namespace.doc(
|
||||
description="Endpoint to delete a Config object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self, config_key):
|
||||
config = Configs.query.filter_by(key=config_key).first_or_404()
|
||||
|
||||
db.session.delete(config)
|
||||
db.session.commit()
|
||||
db.session.close()
|
||||
|
||||
clear_config()
|
||||
clear_standings()
|
||||
clear_challenges()
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@configs_namespace.route("/fields")
|
||||
class FieldList(Resource):
|
||||
@admins_only
|
||||
@validate_args(
|
||||
{
|
||||
"type": (str, None),
|
||||
"q": (str, None),
|
||||
"field": (RawEnum("FieldFields", {"description": "description"}), None),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Fields, query=q, field=field)
|
||||
|
||||
fields = Fields.query.filter_by(**query_args).filter(*filters).all()
|
||||
schema = FieldSchema(many=True)
|
||||
|
||||
response = schema.dump(fields)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
schema = FieldSchema()
|
||||
response = schema.load(req, session=db.session)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
db.session.add(response.data)
|
||||
db.session.commit()
|
||||
|
||||
response = schema.dump(response.data)
|
||||
db.session.close()
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
|
||||
@configs_namespace.route("/fields/<field_id>")
|
||||
class Field(Resource):
|
||||
@admins_only
|
||||
def get(self, field_id):
|
||||
field = Fields.query.filter_by(id=field_id).first_or_404()
|
||||
schema = FieldSchema()
|
||||
|
||||
response = schema.dump(field)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
def patch(self, field_id):
|
||||
field = Fields.query.filter_by(id=field_id).first_or_404()
|
||||
schema = FieldSchema()
|
||||
|
||||
req = request.get_json()
|
||||
|
||||
response = schema.load(req, session=db.session, instance=field)
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
db.session.commit()
|
||||
|
||||
response = schema.dump(response.data)
|
||||
db.session.close()
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
def delete(self, field_id):
|
||||
field = Fields.query.filter_by(id=field_id).first_or_404()
|
||||
db.session.delete(field)
|
||||
db.session.commit()
|
||||
db.session.close()
|
||||
|
||||
return {"success": True}
|
||||
46
CTFd/api/v1/exports.py
Normal file
46
CTFd/api/v1/exports.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from datetime import datetime as DateTime
|
||||
|
||||
from flask import request, send_file
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.utils.config import ctf_name
|
||||
from CTFd.utils.csv import dump_csv
|
||||
from CTFd.utils.decorators import admins_only, ratelimit
|
||||
from CTFd.utils.exports import export_ctf as export_ctf_util
|
||||
|
||||
exports_namespace = Namespace("exports", description="Endpoint to retrieve Exports")
|
||||
|
||||
|
||||
@exports_namespace.route("/raw")
|
||||
class ExportList(Resource):
|
||||
@admins_only
|
||||
@ratelimit(method="POST", limit=10, interval=60)
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
export_type = req.get("type", "_")
|
||||
export_args = req.get("args", {})
|
||||
|
||||
day = DateTime.now().strftime("%Y-%m-%d_%T")
|
||||
if export_type == "csv":
|
||||
table = export_args.get("table")
|
||||
if not table:
|
||||
return {
|
||||
"success": False,
|
||||
"errors": {"args": "Missing table to export"},
|
||||
}, 400
|
||||
output = dump_csv(name=table)
|
||||
return send_file(
|
||||
output,
|
||||
as_attachment=True,
|
||||
max_age=-1,
|
||||
download_name=f"{ctf_name()}-{table}-{day}.csv",
|
||||
)
|
||||
else:
|
||||
backup = export_ctf_util()
|
||||
full_name = f"{ctf_name()}.{day}.zip"
|
||||
return send_file(
|
||||
backup,
|
||||
cache_timeout=-1,
|
||||
as_attachment=True,
|
||||
attachment_filename=full_name,
|
||||
)
|
||||
181
CTFd/api/v1/files.py
Normal file
181
CTFd/api/v1/files.py
Normal file
@@ -0,0 +1,181 @@
|
||||
from typing import List
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import Files, db
|
||||
from CTFd.schemas.files import FileSchema
|
||||
from CTFd.utils import uploads
|
||||
from CTFd.utils.decorators import admins_only
|
||||
from CTFd.utils.helpers.models import build_model_filters
|
||||
|
||||
files_namespace = Namespace("files", description="Endpoint to retrieve Files")
|
||||
|
||||
FileModel = sqlalchemy_to_pydantic(Files)
|
||||
|
||||
|
||||
class FileDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: FileModel
|
||||
|
||||
|
||||
class FileListSuccessResponse(APIListSuccessResponse):
|
||||
data: List[FileModel]
|
||||
|
||||
|
||||
files_namespace.schema_model(
|
||||
"FileDetailedSuccessResponse", FileDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
files_namespace.schema_model(
|
||||
"FileListSuccessResponse", FileListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@files_namespace.route("")
|
||||
class FilesList(Resource):
|
||||
@admins_only
|
||||
@files_namespace.doc(
|
||||
description="Endpoint to get file objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "FileListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"type": (str, None),
|
||||
"location": (str, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum("FileFields", {"type": "type", "location": "location"}),
|
||||
None,
|
||||
),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Files, query=q, field=field)
|
||||
|
||||
files = Files.query.filter_by(**query_args).filter(*filters).all()
|
||||
schema = FileSchema(many=True)
|
||||
response = schema.dump(files)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@files_namespace.doc(
|
||||
description="Endpoint to get file objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "FileDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
params={
|
||||
"file": {
|
||||
"in": "formData",
|
||||
"type": "file",
|
||||
"required": True,
|
||||
"description": "The file to upload",
|
||||
}
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"challenge_id": (int, None),
|
||||
"challenge": (int, None),
|
||||
"page_id": (int, None),
|
||||
"page": (int, None),
|
||||
"solution_id": (int, None),
|
||||
"solution": (int, None),
|
||||
"type": (str, None),
|
||||
"location": (str, None),
|
||||
},
|
||||
location="form",
|
||||
)
|
||||
def post(self, form_args):
|
||||
files = request.files.getlist("file")
|
||||
location = form_args.get("location")
|
||||
# challenge_id
|
||||
# page_id
|
||||
|
||||
# Handle situation where users attempt to upload multiple files with a single location
|
||||
if len(files) > 1 and location:
|
||||
return {
|
||||
"success": False,
|
||||
"errors": {
|
||||
"location": ["Location cannot be specified with multiple files"]
|
||||
},
|
||||
}, 400
|
||||
|
||||
objs = []
|
||||
for f in files:
|
||||
# uploads.upload_file(file=f, chalid=req.get('challenge'))
|
||||
try:
|
||||
obj = uploads.upload_file(file=f, **form_args)
|
||||
except ValueError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"errors": {"location": [str(e)]},
|
||||
}, 400
|
||||
objs.append(obj)
|
||||
|
||||
schema = FileSchema(many=True)
|
||||
response = schema.dump(objs)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
|
||||
@files_namespace.route("/<file_id>")
|
||||
class FilesDetail(Resource):
|
||||
@admins_only
|
||||
@files_namespace.doc(
|
||||
description="Endpoint to get a specific file object",
|
||||
responses={
|
||||
200: ("Success", "FileDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, file_id):
|
||||
f = Files.query.filter_by(id=file_id).first_or_404()
|
||||
schema = FileSchema()
|
||||
response = schema.dump(f)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@files_namespace.doc(
|
||||
description="Endpoint to delete a file object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self, file_id):
|
||||
f = Files.query.filter_by(id=file_id).first_or_404()
|
||||
|
||||
uploads.delete_file(file_id=f.id)
|
||||
db.session.delete(f)
|
||||
db.session.commit()
|
||||
db.session.close()
|
||||
|
||||
return {"success": True}
|
||||
206
CTFd/api/v1/flags.py
Normal file
206
CTFd/api/v1/flags.py
Normal file
@@ -0,0 +1,206 @@
|
||||
from typing import List
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import Flags, db
|
||||
from CTFd.plugins.flags import FLAG_CLASSES, get_flag_class
|
||||
from CTFd.schemas.flags import FlagSchema
|
||||
from CTFd.utils.decorators import admins_only
|
||||
from CTFd.utils.helpers.models import build_model_filters
|
||||
|
||||
flags_namespace = Namespace("flags", description="Endpoint to retrieve Flags")
|
||||
|
||||
FlagModel = sqlalchemy_to_pydantic(Flags)
|
||||
|
||||
|
||||
class FlagDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: FlagModel
|
||||
|
||||
|
||||
class FlagListSuccessResponse(APIListSuccessResponse):
|
||||
data: List[FlagModel]
|
||||
|
||||
|
||||
flags_namespace.schema_model(
|
||||
"FlagDetailedSuccessResponse", FlagDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
flags_namespace.schema_model(
|
||||
"FlagListSuccessResponse", FlagListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@flags_namespace.route("")
|
||||
class FlagList(Resource):
|
||||
@admins_only
|
||||
@flags_namespace.doc(
|
||||
description="Endpoint to list Flag objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "FlagListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"challenge_id": (int, None),
|
||||
"type": (str, None),
|
||||
"content": (str, None),
|
||||
"data": (str, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum(
|
||||
"FlagFields", {"type": "type", "content": "content", "data": "data"}
|
||||
),
|
||||
None,
|
||||
),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Flags, query=q, field=field)
|
||||
|
||||
flags = Flags.query.filter_by(**query_args).filter(*filters).all()
|
||||
schema = FlagSchema(many=True)
|
||||
response = schema.dump(flags)
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@flags_namespace.doc(
|
||||
description="Endpoint to create a Flag object",
|
||||
responses={
|
||||
200: ("Success", "FlagDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
schema = FlagSchema()
|
||||
response = schema.load(req, session=db.session)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
# We only want to operate on flag types where are have
|
||||
# high confidence a leading/trailing space was not intentional
|
||||
if response.data.type in ("static", "regex"):
|
||||
response.data.content = response.data.content.strip()
|
||||
|
||||
db.session.add(response.data)
|
||||
db.session.commit()
|
||||
|
||||
response = schema.dump(response.data)
|
||||
db.session.close()
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
|
||||
@flags_namespace.route("/types")
|
||||
class FlagTypes(Resource):
|
||||
@admins_only
|
||||
def get(self):
|
||||
response = {}
|
||||
for class_id in FLAG_CLASSES:
|
||||
flag_class = FLAG_CLASSES.get(class_id)
|
||||
response[class_id] = {
|
||||
"name": flag_class.name,
|
||||
"templates": flag_class.templates,
|
||||
}
|
||||
return {"success": True, "data": response}
|
||||
|
||||
|
||||
@flags_namespace.route("/types/<type_name>")
|
||||
class FlagType(Resource):
|
||||
@admins_only
|
||||
def get(self, type_name):
|
||||
flag_class = get_flag_class(type_name)
|
||||
response = {"name": flag_class.name, "templates": flag_class.templates}
|
||||
return {"success": True, "data": response}
|
||||
|
||||
|
||||
@flags_namespace.route("/<flag_id>")
|
||||
class Flag(Resource):
|
||||
@admins_only
|
||||
@flags_namespace.doc(
|
||||
description="Endpoint to get a specific Flag object",
|
||||
responses={
|
||||
200: ("Success", "FlagDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, flag_id):
|
||||
flag = Flags.query.filter_by(id=flag_id).first_or_404()
|
||||
schema = FlagSchema()
|
||||
response = schema.dump(flag)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
response.data["templates"] = get_flag_class(flag.type).templates
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@flags_namespace.doc(
|
||||
description="Endpoint to delete a specific Flag object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self, flag_id):
|
||||
flag = Flags.query.filter_by(id=flag_id).first_or_404()
|
||||
|
||||
db.session.delete(flag)
|
||||
db.session.commit()
|
||||
db.session.close()
|
||||
|
||||
return {"success": True}
|
||||
|
||||
@admins_only
|
||||
@flags_namespace.doc(
|
||||
description="Endpoint to edit a specific Flag object",
|
||||
responses={
|
||||
200: ("Success", "FlagDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def patch(self, flag_id):
|
||||
flag = Flags.query.filter_by(id=flag_id).first_or_404()
|
||||
schema = FlagSchema()
|
||||
req = request.get_json()
|
||||
|
||||
# We only want to operate on flag types where are have
|
||||
# high confidence a leading/trailing space was not intentional
|
||||
if flag.type in ("static", "regex") and req.get("content"):
|
||||
req["content"] = req["content"].strip()
|
||||
|
||||
response = schema.load(req, session=db.session, instance=flag, partial=True)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
db.session.commit()
|
||||
|
||||
response = schema.dump(response.data)
|
||||
db.session.close()
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
0
CTFd/api/v1/helpers/__init__.py
Normal file
0
CTFd/api/v1/helpers/__init__.py
Normal file
12
CTFd/api/v1/helpers/models.py
Normal file
12
CTFd/api/v1/helpers/models.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# This file is no longer used. If you're importing the function from here please update your imports
|
||||
|
||||
from CTFd.utils.helpers.models import build_model_filters as _build_model_filters
|
||||
|
||||
|
||||
def build_model_filters(model, query, field):
|
||||
print("CTFd.api.v1.helpers.models.build_model_filters has been deprecated.")
|
||||
print("Please switch to using CTFd.utils.helpers.models.build_model_filters")
|
||||
print(
|
||||
"This function will raise an exception in a future minor release of CTFd and then be removed in a major release."
|
||||
)
|
||||
return _build_model_filters(model, query, field)
|
||||
98
CTFd/api/v1/helpers/request.py
Normal file
98
CTFd/api/v1/helpers/request.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from functools import wraps
|
||||
|
||||
from flask import request
|
||||
from pydantic import ValidationError, create_model
|
||||
|
||||
ARG_LOCATIONS = {
|
||||
"query": lambda: request.args,
|
||||
"json": lambda: request.get_json(),
|
||||
"formData": lambda: request.form,
|
||||
"headers": lambda: request.headers,
|
||||
"cookies": lambda: request.cookies,
|
||||
}
|
||||
|
||||
|
||||
def validate_args(spec, location):
|
||||
"""
|
||||
A rough implementation of webargs using pydantic schemas. You can pass a
|
||||
pydantic schema as spec or create it on the fly as follows:
|
||||
|
||||
@validate_args({"name": (str, None), "id": (int, None)}, location="query")
|
||||
"""
|
||||
if isinstance(spec, dict):
|
||||
spec = create_model("", **spec)
|
||||
|
||||
schema = spec.schema()
|
||||
|
||||
defs = schema.pop("definitions", {})
|
||||
props = schema.get("properties", {})
|
||||
required = schema.get("required", [])
|
||||
|
||||
# Remove all titles and resolve all $refs in properties
|
||||
for k in props:
|
||||
if "title" in props[k]:
|
||||
del props[k]["title"]
|
||||
|
||||
if "$ref" in props[k]:
|
||||
definition: dict = defs[props[k].pop("$ref").split("/").pop()]
|
||||
|
||||
# Check if the schema is for enums, if so, we just add in the "enum" key
|
||||
# else we add the whole schema into the properties
|
||||
if "enum" in definition:
|
||||
props[k]["enum"] = definition["enum"]
|
||||
else:
|
||||
props[k] = definition
|
||||
|
||||
def decorator(func):
|
||||
# Inject parameters information into the Flask-Restx apidoc attribute.
|
||||
# Not really a good solution. See https://github.com/CTFd/CTFd/issues/1504
|
||||
nonlocal location
|
||||
|
||||
apidoc = getattr(func, "__apidoc__", {"params": {}})
|
||||
|
||||
if location == "form":
|
||||
location = "formData"
|
||||
|
||||
if any(v["type"] == "file" for v in props.values()):
|
||||
apidoc["consumes"] = ["multipart/form-data"]
|
||||
else:
|
||||
apidoc["consumes"] = [
|
||||
"application/x-www-form-urlencoded",
|
||||
"multipart/form-data",
|
||||
]
|
||||
|
||||
if location == "json":
|
||||
title = schema.get("title", "")
|
||||
apidoc["consumes"] = ["application/json"]
|
||||
apidoc["params"].update({title: {"in": "body", "schema": schema}})
|
||||
else:
|
||||
for k, v in props.items():
|
||||
v["in"] = location
|
||||
|
||||
if k in required:
|
||||
v["required"] = True
|
||||
|
||||
apidoc["params"][k] = v
|
||||
|
||||
func.__apidoc__ = apidoc
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
data = ARG_LOCATIONS[location]()
|
||||
try:
|
||||
# Try to load data according to pydantic spec
|
||||
loaded = spec(**data).dict(exclude_unset=True)
|
||||
except ValidationError as e:
|
||||
# Handle reporting errors when invalid
|
||||
resp = {}
|
||||
errors = e.errors()
|
||||
for err in errors:
|
||||
loc = err["loc"][0]
|
||||
msg = err["msg"]
|
||||
resp[loc] = msg
|
||||
return {"success": False, "errors": resp}, 400
|
||||
return func(*args, loaded, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
35
CTFd/api/v1/helpers/schemas.py
Normal file
35
CTFd/api/v1/helpers/schemas.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from typing import Container, Dict, Type
|
||||
|
||||
from pydantic import BaseModel, create_model
|
||||
from sqlalchemy.inspection import inspect
|
||||
from sqlalchemy.orm.properties import ColumnProperty
|
||||
|
||||
|
||||
def sqlalchemy_to_pydantic(
|
||||
db_model: Type, *, include: Dict[str, type] = None, exclude: Container[str] = None
|
||||
) -> Type[BaseModel]:
|
||||
"""
|
||||
Mostly copied from https://github.com/tiangolo/pydantic-sqlalchemy
|
||||
"""
|
||||
if exclude is None:
|
||||
exclude = []
|
||||
mapper = inspect(db_model)
|
||||
fields = {}
|
||||
for attr in mapper.attrs:
|
||||
if isinstance(attr, ColumnProperty):
|
||||
if attr.columns:
|
||||
column = attr.columns[0]
|
||||
python_type = column.type.python_type
|
||||
name = attr.key
|
||||
if name in exclude:
|
||||
continue
|
||||
default = None
|
||||
if column.default is None and not column.nullable:
|
||||
default = ...
|
||||
fields[name] = (python_type, default)
|
||||
if bool(include):
|
||||
for name, python_type in include.items():
|
||||
default = None
|
||||
fields[name] = (python_type, default)
|
||||
pydantic_model = create_model(db_model.__name__, **fields) # type: ignore
|
||||
return pydantic_model
|
||||
237
CTFd/api/v1/hints.py
Normal file
237
CTFd/api/v1/hints.py
Normal file
@@ -0,0 +1,237 @@
|
||||
from typing import List
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import Hints, HintUnlocks, db
|
||||
from CTFd.schemas.hints import HintSchema
|
||||
from CTFd.utils import get_config
|
||||
from CTFd.utils.decorators import admins_only, during_ctf_time_only
|
||||
from CTFd.utils.decorators.visibility import check_challenge_visibility
|
||||
from CTFd.utils.helpers.models import build_model_filters
|
||||
from CTFd.utils.user import get_current_user, is_admin
|
||||
|
||||
hints_namespace = Namespace("hints", description="Endpoint to retrieve Hints")
|
||||
|
||||
HintModel = sqlalchemy_to_pydantic(Hints)
|
||||
|
||||
|
||||
class HintDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: HintModel
|
||||
|
||||
|
||||
class HintListSuccessResponse(APIListSuccessResponse):
|
||||
data: List[HintModel]
|
||||
|
||||
|
||||
hints_namespace.schema_model(
|
||||
"HintDetailedSuccessResponse", HintDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
hints_namespace.schema_model(
|
||||
"HintListSuccessResponse", HintListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@hints_namespace.route("")
|
||||
class HintList(Resource):
|
||||
@admins_only
|
||||
@hints_namespace.doc(
|
||||
description="Endpoint to list Hint objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "HintListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"type": (str, None),
|
||||
"challenge_id": (int, None),
|
||||
"content": (str, None),
|
||||
"cost": (int, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum("HintFields", {"type": "type", "content": "content"}),
|
||||
None,
|
||||
),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Hints, query=q, field=field)
|
||||
|
||||
hints = Hints.query.filter_by(**query_args).filter(*filters).all()
|
||||
response = HintSchema(many=True, view="locked").dump(hints)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@hints_namespace.doc(
|
||||
description="Endpoint to create a Hint object",
|
||||
responses={
|
||||
200: ("Success", "HintDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
schema = HintSchema(view="admin")
|
||||
response = schema.load(req, session=db.session)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
db.session.add(response.data)
|
||||
db.session.commit()
|
||||
|
||||
response = schema.dump(response.data)
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
|
||||
@hints_namespace.route("/<hint_id>")
|
||||
class Hint(Resource):
|
||||
@during_ctf_time_only
|
||||
@check_challenge_visibility
|
||||
@hints_namespace.doc(
|
||||
description="Endpoint to get a specific Hint object",
|
||||
responses={
|
||||
200: ("Success", "HintDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, hint_id):
|
||||
hint = Hints.query.filter_by(id=hint_id).first_or_404()
|
||||
user = get_current_user()
|
||||
|
||||
# We allow public accessing of hints if challenges are visible and there is no cost or prerequisites
|
||||
# If there is a cost or a prereq we should block the user from seeing the hint
|
||||
if user is None:
|
||||
if hint.cost or hint.prerequisites:
|
||||
return (
|
||||
{
|
||||
"success": False,
|
||||
"errors": {"cost": ["You must login to unlock this hint"]},
|
||||
},
|
||||
403,
|
||||
)
|
||||
else:
|
||||
# Allow admins to allow public CTFs to see free hints if they wish
|
||||
if bool(get_config("hints_free_public_access", False)) is True:
|
||||
response = HintSchema(view="unlocked").dump(hint)
|
||||
return {"success": True, "data": response.data}
|
||||
else:
|
||||
return (
|
||||
{
|
||||
"success": False,
|
||||
"errors": {"cost": ["You must login to unlock this hint"]},
|
||||
},
|
||||
403,
|
||||
)
|
||||
|
||||
if hint.prerequisites:
|
||||
requirements = hint.prerequisites
|
||||
|
||||
# Get the IDs of all hints that the user has unlocked
|
||||
all_unlocks = HintUnlocks.query.filter_by(account_id=user.account_id).all()
|
||||
unlock_ids = {unlock.target for unlock in all_unlocks}
|
||||
|
||||
# Filter out hint IDs that don't exist
|
||||
all_hint_ids = {h.id for h in Hints.query.with_entities(Hints.id).all()}
|
||||
prereqs = set(requirements).intersection(all_hint_ids)
|
||||
|
||||
# If the user has the necessary unlocks or is admin we should allow them to view
|
||||
if unlock_ids >= prereqs:
|
||||
pass
|
||||
else:
|
||||
if is_admin() and request.args.get("preview", False):
|
||||
pass
|
||||
else:
|
||||
return (
|
||||
{
|
||||
"success": False,
|
||||
"errors": {
|
||||
"requirements": [
|
||||
"You must unlock other hints before accessing this hint"
|
||||
]
|
||||
},
|
||||
},
|
||||
403,
|
||||
)
|
||||
|
||||
view = "locked"
|
||||
unlocked = HintUnlocks.query.filter_by(
|
||||
account_id=user.account_id, target=hint.id
|
||||
).first()
|
||||
if unlocked:
|
||||
view = "unlocked"
|
||||
|
||||
if is_admin():
|
||||
if request.args.get("preview", False):
|
||||
view = "admin"
|
||||
|
||||
response = HintSchema(view=view).dump(hint)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@hints_namespace.doc(
|
||||
description="Endpoint to edit a specific Hint object",
|
||||
responses={
|
||||
200: ("Success", "HintDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def patch(self, hint_id):
|
||||
hint = Hints.query.filter_by(id=hint_id).first_or_404()
|
||||
req = request.get_json()
|
||||
|
||||
schema = HintSchema(view="admin")
|
||||
response = schema.load(req, instance=hint, partial=True, session=db.session)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
db.session.add(response.data)
|
||||
db.session.commit()
|
||||
|
||||
response = schema.dump(response.data)
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@hints_namespace.doc(
|
||||
description="Endpoint to delete a specific Tag object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self, hint_id):
|
||||
hint = Hints.query.filter_by(id=hint_id).first_or_404()
|
||||
db.session.delete(hint)
|
||||
db.session.commit()
|
||||
db.session.close()
|
||||
|
||||
return {"success": True}
|
||||
189
CTFd/api/v1/notifications.py
Normal file
189
CTFd/api/v1/notifications.py
Normal file
@@ -0,0 +1,189 @@
|
||||
from typing import List
|
||||
|
||||
from flask import current_app, make_response, request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import Notifications, db
|
||||
from CTFd.schemas.notifications import NotificationSchema
|
||||
from CTFd.utils.decorators import admins_only
|
||||
from CTFd.utils.helpers.models import build_model_filters
|
||||
|
||||
notifications_namespace = Namespace(
|
||||
"notifications", description="Endpoint to retrieve Notifications"
|
||||
)
|
||||
|
||||
NotificationModel = sqlalchemy_to_pydantic(Notifications)
|
||||
TransientNotificationModel = sqlalchemy_to_pydantic(Notifications, exclude=["id"])
|
||||
|
||||
|
||||
class NotificationDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: NotificationModel
|
||||
|
||||
|
||||
class NotificationListSuccessResponse(APIListSuccessResponse):
|
||||
data: List[NotificationModel]
|
||||
|
||||
|
||||
notifications_namespace.schema_model(
|
||||
"NotificationDetailedSuccessResponse", NotificationDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
notifications_namespace.schema_model(
|
||||
"NotificationListSuccessResponse", NotificationListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@notifications_namespace.route("")
|
||||
class NotificantionList(Resource):
|
||||
@notifications_namespace.doc(
|
||||
description="Endpoint to get notification objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "NotificationListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"title": (str, None),
|
||||
"content": (str, None),
|
||||
"user_id": (int, None),
|
||||
"team_id": (int, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum("NotificationFields", {"title": "title", "content": "content"}),
|
||||
None,
|
||||
),
|
||||
"since_id": (int, None),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Notifications, query=q, field=field)
|
||||
|
||||
since_id = query_args.pop("since_id", None)
|
||||
if since_id:
|
||||
filters.append((Notifications.id > since_id))
|
||||
|
||||
notifications = (
|
||||
Notifications.query.filter_by(**query_args).filter(*filters).all()
|
||||
)
|
||||
schema = NotificationSchema(many=True)
|
||||
result = schema.dump(notifications)
|
||||
if result.errors:
|
||||
return {"success": False, "errors": result.errors}, 400
|
||||
return {"success": True, "data": result.data}
|
||||
|
||||
@notifications_namespace.doc(
|
||||
description="Endpoint to get statistics for notification objects in bulk",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"title": (str, None),
|
||||
"content": (str, None),
|
||||
"user_id": (int, None),
|
||||
"team_id": (int, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum("NotificationFields", {"title": "title", "content": "content"}),
|
||||
None,
|
||||
),
|
||||
"since_id": (int, None),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def head(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Notifications, query=q, field=field)
|
||||
|
||||
since_id = query_args.pop("since_id", None)
|
||||
if since_id:
|
||||
filters.append((Notifications.id > since_id))
|
||||
|
||||
notification_count = (
|
||||
Notifications.query.filter_by(**query_args).filter(*filters).count()
|
||||
)
|
||||
response = make_response()
|
||||
response.headers["Result-Count"] = notification_count
|
||||
return response
|
||||
|
||||
@admins_only
|
||||
@notifications_namespace.doc(
|
||||
description="Endpoint to create a notification object",
|
||||
responses={
|
||||
200: ("Success", "NotificationDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
|
||||
schema = NotificationSchema()
|
||||
result = schema.load(req)
|
||||
|
||||
if result.errors:
|
||||
return {"success": False, "errors": result.errors}, 400
|
||||
|
||||
db.session.add(result.data)
|
||||
db.session.commit()
|
||||
|
||||
response = schema.dump(result.data)
|
||||
|
||||
# Grab additional settings
|
||||
notif_type = req.get("type", "alert")
|
||||
notif_sound = req.get("sound", True)
|
||||
response.data["type"] = notif_type
|
||||
response.data["sound"] = notif_sound
|
||||
|
||||
current_app.events_manager.publish(data=response.data, type="notification")
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
|
||||
@notifications_namespace.route("/<notification_id>")
|
||||
@notifications_namespace.param("notification_id", "A Notification ID")
|
||||
class Notification(Resource):
|
||||
@notifications_namespace.doc(
|
||||
description="Endpoint to get a specific notification object",
|
||||
responses={
|
||||
200: ("Success", "NotificationDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, notification_id):
|
||||
notif = Notifications.query.filter_by(id=notification_id).first_or_404()
|
||||
schema = NotificationSchema()
|
||||
response = schema.dump(notif)
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@notifications_namespace.doc(
|
||||
description="Endpoint to delete a notification object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self, notification_id):
|
||||
notif = Notifications.query.filter_by(id=notification_id).first_or_404()
|
||||
db.session.delete(notif)
|
||||
db.session.commit()
|
||||
db.session.close()
|
||||
|
||||
return {"success": True}
|
||||
177
CTFd/api/v1/pages.py
Normal file
177
CTFd/api/v1/pages.py
Normal file
@@ -0,0 +1,177 @@
|
||||
from typing import List
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||
from CTFd.cache import clear_pages
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import Pages, db
|
||||
from CTFd.schemas.pages import PageSchema
|
||||
from CTFd.utils.decorators import admins_only
|
||||
from CTFd.utils.helpers.models import build_model_filters
|
||||
|
||||
pages_namespace = Namespace("pages", description="Endpoint to retrieve Pages")
|
||||
|
||||
|
||||
PageModel = sqlalchemy_to_pydantic(Pages)
|
||||
TransientPageModel = sqlalchemy_to_pydantic(Pages, exclude=["id"])
|
||||
|
||||
|
||||
class PageDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: PageModel
|
||||
|
||||
|
||||
class PageListSuccessResponse(APIListSuccessResponse):
|
||||
data: List[PageModel]
|
||||
|
||||
|
||||
pages_namespace.schema_model(
|
||||
"PageDetailedSuccessResponse", PageDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
pages_namespace.schema_model(
|
||||
"PageListSuccessResponse", PageListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@pages_namespace.route("")
|
||||
@pages_namespace.doc(
|
||||
responses={200: "Success", 400: "An error occured processing your data"}
|
||||
)
|
||||
class PageList(Resource):
|
||||
@admins_only
|
||||
@pages_namespace.doc(
|
||||
description="Endpoint to get page objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "PageListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"id": (int, None),
|
||||
"title": (str, None),
|
||||
"route": (str, None),
|
||||
"draft": (bool, None),
|
||||
"hidden": (bool, None),
|
||||
"auth_required": (bool, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum(
|
||||
"PageFields",
|
||||
{"title": "title", "route": "route", "content": "content"},
|
||||
),
|
||||
None,
|
||||
),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Pages, query=q, field=field)
|
||||
|
||||
pages = Pages.query.filter_by(**query_args).filter(*filters).all()
|
||||
schema = PageSchema(exclude=["content"], many=True)
|
||||
response = schema.dump(pages)
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@pages_namespace.doc(
|
||||
description="Endpoint to create a page object",
|
||||
responses={
|
||||
200: ("Success", "PageDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(TransientPageModel, location="json")
|
||||
def post(self, json_args):
|
||||
req = json_args
|
||||
schema = PageSchema()
|
||||
response = schema.load(req)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
db.session.add(response.data)
|
||||
db.session.commit()
|
||||
|
||||
response = schema.dump(response.data)
|
||||
db.session.close()
|
||||
|
||||
clear_pages()
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
|
||||
@pages_namespace.route("/<page_id>")
|
||||
@pages_namespace.doc(
|
||||
params={"page_id": "ID of a page object"},
|
||||
responses={
|
||||
200: ("Success", "PageDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
class PageDetail(Resource):
|
||||
@admins_only
|
||||
@pages_namespace.doc(description="Endpoint to read a page object")
|
||||
def get(self, page_id):
|
||||
page = Pages.query.filter_by(id=page_id).first_or_404()
|
||||
schema = PageSchema()
|
||||
response = schema.dump(page)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@pages_namespace.doc(description="Endpoint to edit a page object")
|
||||
def patch(self, page_id):
|
||||
page = Pages.query.filter_by(id=page_id).first_or_404()
|
||||
req = request.get_json()
|
||||
|
||||
schema = PageSchema(partial=True)
|
||||
response = schema.load(req, instance=page, partial=True)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
db.session.commit()
|
||||
|
||||
response = schema.dump(response.data)
|
||||
db.session.close()
|
||||
|
||||
clear_pages()
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@pages_namespace.doc(
|
||||
description="Endpoint to delete a page object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self, page_id):
|
||||
page = Pages.query.filter_by(id=page_id).first_or_404()
|
||||
db.session.delete(page)
|
||||
db.session.commit()
|
||||
db.session.close()
|
||||
|
||||
clear_pages()
|
||||
|
||||
return {"success": True}
|
||||
105
CTFd/api/v1/schemas/__init__.py
Normal file
105
CTFd/api/v1/schemas/__init__.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class APISimpleSuccessResponse(BaseModel):
|
||||
success: bool = True
|
||||
|
||||
|
||||
class APIDetailedSuccessResponse(APISimpleSuccessResponse):
|
||||
data: Optional[Any]
|
||||
|
||||
@classmethod
|
||||
def apidoc(cls):
|
||||
"""
|
||||
Helper to inline references from the generated schema
|
||||
"""
|
||||
schema = cls.schema()
|
||||
|
||||
try:
|
||||
key = schema["properties"]["data"]["$ref"]
|
||||
ref = key.split("/").pop()
|
||||
definition = schema["definitions"][ref]
|
||||
schema["properties"]["data"] = definition
|
||||
del schema["definitions"][ref]
|
||||
if bool(schema["definitions"]) is False:
|
||||
del schema["definitions"]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
class APIListSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: Optional[List[Any]]
|
||||
|
||||
@classmethod
|
||||
def apidoc(cls):
|
||||
"""
|
||||
Helper to inline references from the generated schema
|
||||
"""
|
||||
schema = cls.schema()
|
||||
|
||||
try:
|
||||
key = schema["properties"]["data"]["items"]["$ref"]
|
||||
ref = key.split("/").pop()
|
||||
definition = schema["definitions"][ref]
|
||||
schema["properties"]["data"]["items"] = definition
|
||||
del schema["definitions"][ref]
|
||||
if bool(schema["definitions"]) is False:
|
||||
del schema["definitions"]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
class PaginatedAPIListSuccessResponse(APIListSuccessResponse):
|
||||
meta: Dict[str, Any]
|
||||
|
||||
@classmethod
|
||||
def apidoc(cls):
|
||||
"""
|
||||
Helper to inline references from the generated schema
|
||||
"""
|
||||
schema = cls.schema()
|
||||
|
||||
schema["properties"]["meta"] = {
|
||||
"title": "Meta",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pagination": {
|
||||
"title": "Pagination",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page": {"title": "Page", "type": "integer"},
|
||||
"next": {"title": "Next", "type": "integer"},
|
||||
"prev": {"title": "Prev", "type": "integer"},
|
||||
"pages": {"title": "Pages", "type": "integer"},
|
||||
"per_page": {"title": "Per Page", "type": "integer"},
|
||||
"total": {"title": "Total", "type": "integer"},
|
||||
},
|
||||
"required": ["page", "next", "prev", "pages", "per_page", "total"],
|
||||
}
|
||||
},
|
||||
"required": ["pagination"],
|
||||
}
|
||||
|
||||
try:
|
||||
key = schema["properties"]["data"]["items"]["$ref"]
|
||||
ref = key.split("/").pop()
|
||||
definition = schema["definitions"][ref]
|
||||
schema["properties"]["data"]["items"] = definition
|
||||
del schema["definitions"][ref]
|
||||
if bool(schema["definitions"]) is False:
|
||||
del schema["definitions"]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
class APISimpleErrorResponse(BaseModel):
|
||||
success: bool = False
|
||||
errors: Optional[List[str]]
|
||||
99
CTFd/api/v1/scoreboard.py
Normal file
99
CTFd/api/v1/scoreboard.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from collections import defaultdict
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Namespace, Resource
|
||||
from sqlalchemy import select
|
||||
|
||||
from CTFd.cache import cache, make_cache_key
|
||||
from CTFd.models import Brackets, Users, db
|
||||
from CTFd.utils import get_config
|
||||
from CTFd.utils.decorators.visibility import (
|
||||
check_account_visibility,
|
||||
check_score_visibility,
|
||||
)
|
||||
from CTFd.utils.modes import TEAMS_MODE, generate_account_url, get_mode_as_word
|
||||
from CTFd.utils.scoreboard import get_scoreboard_detail
|
||||
from CTFd.utils.scores import get_standings, get_user_standings
|
||||
|
||||
scoreboard_namespace = Namespace(
|
||||
"scoreboard", description="Endpoint to retrieve scores"
|
||||
)
|
||||
|
||||
|
||||
@scoreboard_namespace.route("")
|
||||
class ScoreboardList(Resource):
|
||||
@check_account_visibility
|
||||
@check_score_visibility
|
||||
@cache.cached(timeout=60, key_prefix=make_cache_key)
|
||||
def get(self):
|
||||
standings = get_standings()
|
||||
response = []
|
||||
mode = get_config("user_mode")
|
||||
account_type = get_mode_as_word()
|
||||
|
||||
if mode == TEAMS_MODE:
|
||||
r = db.session.execute(
|
||||
select(
|
||||
[
|
||||
Users.id,
|
||||
Users.name,
|
||||
Users.oauth_id,
|
||||
Users.team_id,
|
||||
Users.hidden,
|
||||
Users.banned,
|
||||
Users.bracket_id,
|
||||
Brackets.name.label("bracket_name"),
|
||||
]
|
||||
)
|
||||
.where(Users.team_id.isnot(None))
|
||||
.join(Brackets, Users.bracket_id == Brackets.id, isouter=True)
|
||||
)
|
||||
users = r.fetchall()
|
||||
membership = defaultdict(dict)
|
||||
for u in users:
|
||||
if u.hidden is False and u.banned is False:
|
||||
membership[u.team_id][u.id] = {
|
||||
"id": u.id,
|
||||
"oauth_id": u.oauth_id,
|
||||
"name": u.name,
|
||||
"score": 0,
|
||||
"bracket_id": u.bracket_id,
|
||||
"bracket_name": u.bracket_name,
|
||||
}
|
||||
|
||||
# Get user_standings as a dict so that we can more quickly get member scores
|
||||
user_standings = get_user_standings()
|
||||
for u in user_standings:
|
||||
membership[u.team_id][u.user_id]["score"] = int(u.score)
|
||||
|
||||
for i, x in enumerate(standings):
|
||||
entry = {
|
||||
"pos": i + 1,
|
||||
"account_id": x.account_id,
|
||||
"account_url": generate_account_url(account_id=x.account_id),
|
||||
"account_type": account_type,
|
||||
"oauth_id": x.oauth_id,
|
||||
"name": x.name,
|
||||
"score": int(x.score),
|
||||
"bracket_id": x.bracket_id,
|
||||
"bracket_name": x.bracket_name,
|
||||
}
|
||||
|
||||
if mode == TEAMS_MODE:
|
||||
entry["members"] = list(membership[x.account_id].values())
|
||||
|
||||
response.append(entry)
|
||||
return {"success": True, "data": response}
|
||||
|
||||
|
||||
@scoreboard_namespace.route("/top/<int:count>")
|
||||
@scoreboard_namespace.param("count", "How many top teams to return")
|
||||
class ScoreboardDetail(Resource):
|
||||
@check_account_visibility
|
||||
@check_score_visibility
|
||||
def get(self, count):
|
||||
# Restrict count to some limit
|
||||
count = max(1, min(count, 50))
|
||||
bracket_id = request.args.get("bracket_id")
|
||||
response = get_scoreboard_detail(count=count, bracket_id=bracket_id)
|
||||
return {"success": True, "data": response}
|
||||
25
CTFd/api/v1/shares.py
Normal file
25
CTFd/api/v1/shares.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from flask import abort, request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.utils import get_config
|
||||
from CTFd.utils.decorators import authed_only
|
||||
from CTFd.utils.social import get_social_share
|
||||
from CTFd.utils.user import get_current_user_attrs
|
||||
|
||||
shares_namespace = Namespace("shares", description="Endpoint to create Share links")
|
||||
|
||||
|
||||
@shares_namespace.route("")
|
||||
class Share(Resource):
|
||||
@authed_only
|
||||
def post(self):
|
||||
if bool(get_config("social_shares", default=True)) is False:
|
||||
abort(403)
|
||||
req = request.get_json()
|
||||
share_type = req.get("type")
|
||||
|
||||
SocialShare = get_social_share(type=share_type)
|
||||
user = get_current_user_attrs()
|
||||
share = SocialShare(user_id=user.id)
|
||||
response = {"url": share.url}
|
||||
return {"success": True, "data": response}
|
||||
235
CTFd/api/v1/solutions.py
Normal file
235
CTFd/api/v1/solutions.py
Normal file
@@ -0,0 +1,235 @@
|
||||
from typing import List
|
||||
|
||||
from flask import abort, request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import Solutions, SolutionUnlocks, db
|
||||
from CTFd.schemas.solutions import SolutionSchema
|
||||
from CTFd.utils.challenges import get_solve_ids_for_user_id
|
||||
from CTFd.utils.decorators import (
|
||||
admins_only,
|
||||
authed_only,
|
||||
during_ctf_time_only,
|
||||
require_verified_emails,
|
||||
)
|
||||
from CTFd.utils.helpers.models import build_model_filters
|
||||
from CTFd.utils.user import get_current_user, is_admin
|
||||
|
||||
solutions_namespace = Namespace(
|
||||
"solutions", description="Endpoint to retrieve and create Solutions"
|
||||
)
|
||||
|
||||
SolutionModel = sqlalchemy_to_pydantic(Solutions)
|
||||
TransientSolutionModel = sqlalchemy_to_pydantic(Solutions, exclude=["id"])
|
||||
|
||||
|
||||
class SolutionDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: SolutionModel
|
||||
|
||||
|
||||
class SolutionListSuccessResponse(APIListSuccessResponse):
|
||||
data: List[SolutionModel]
|
||||
|
||||
|
||||
solutions_namespace.schema_model(
|
||||
"SolutionDetailedSuccessResponse", SolutionDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
solutions_namespace.schema_model(
|
||||
"SolutionListSuccessResponse", SolutionListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@solutions_namespace.route("")
|
||||
class SolutionList(Resource):
|
||||
@admins_only
|
||||
@solutions_namespace.doc(
|
||||
description="Endpoint to get solution objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "SolutionListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"state": (str, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum(
|
||||
"SolutionFields",
|
||||
{
|
||||
"state": "state",
|
||||
},
|
||||
),
|
||||
None,
|
||||
),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Solutions, query=q, field=field)
|
||||
|
||||
args = query_args
|
||||
schema = SolutionSchema(many=True)
|
||||
|
||||
solutions = Solutions.query.filter_by(**args).filter(*filters).all()
|
||||
|
||||
response = schema.dump(solutions)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": response.data,
|
||||
}
|
||||
|
||||
@admins_only
|
||||
@solutions_namespace.doc(
|
||||
description="Endpoint to create a solution object",
|
||||
responses={
|
||||
200: ("Success", "SolutionDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
403: (
|
||||
"Access denied",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
TransientSolutionModel,
|
||||
location="json",
|
||||
)
|
||||
def post(self, json_args):
|
||||
req = json_args
|
||||
|
||||
# Set default state if not provided
|
||||
if "state" not in req or req["state"] is None:
|
||||
req["state"] = "hidden"
|
||||
|
||||
solution = Solutions(**req)
|
||||
schema = SolutionSchema()
|
||||
|
||||
db.session.add(solution)
|
||||
db.session.commit()
|
||||
|
||||
response = schema.dump(solution)
|
||||
db.session.close()
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
|
||||
@solutions_namespace.route("/<solution_id>")
|
||||
@solutions_namespace.param("solution_id", "A Solution ID")
|
||||
class Solution(Resource):
|
||||
@during_ctf_time_only
|
||||
@require_verified_emails
|
||||
@authed_only
|
||||
@solutions_namespace.doc(
|
||||
description="Endpoint to get a solution object",
|
||||
responses={
|
||||
200: ("Success", "SolutionDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, solution_id):
|
||||
solution = Solutions.query.filter_by(id=solution_id).first_or_404()
|
||||
|
||||
user = get_current_user()
|
||||
|
||||
if is_admin():
|
||||
view = "admin"
|
||||
else:
|
||||
if solution.state == "hidden":
|
||||
abort(404)
|
||||
if solution.challenge.state == "hidden":
|
||||
abort(404)
|
||||
|
||||
# If the solution state is visible we just let it through
|
||||
if solution.state == "visible":
|
||||
pass
|
||||
elif solution.state == "solved":
|
||||
user_solves = get_solve_ids_for_user_id(user_id=user.id)
|
||||
if solution.challenge.id not in user_solves:
|
||||
abort(404)
|
||||
else:
|
||||
# Different behavior can be implemented in a plugin for the route
|
||||
abort(404)
|
||||
|
||||
view = "locked"
|
||||
unlocked = SolutionUnlocks.query.filter_by(
|
||||
account_id=user.account_id, target=solution.id
|
||||
).first()
|
||||
if unlocked:
|
||||
view = "unlocked"
|
||||
|
||||
schema = SolutionSchema(view=view)
|
||||
response = schema.dump(solution)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@solutions_namespace.doc(
|
||||
description="Endpoint to edit a solution object",
|
||||
responses={
|
||||
200: ("Success", "SolutionDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def patch(self, solution_id):
|
||||
solution = Solutions.query.filter_by(id=solution_id).first_or_404()
|
||||
|
||||
req = request.get_json()
|
||||
schema = SolutionSchema(partial=True)
|
||||
|
||||
response = schema.load(req, instance=solution, partial=True)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
db.session.commit()
|
||||
|
||||
response = schema.dump(solution)
|
||||
db.session.close()
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@solutions_namespace.doc(
|
||||
description="Endpoint to delete a solution object",
|
||||
responses={
|
||||
200: ("Success", "APISimpleSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def delete(self, solution_id):
|
||||
solution = Solutions.query.filter_by(id=solution_id).first_or_404()
|
||||
|
||||
db.session.delete(solution)
|
||||
db.session.commit()
|
||||
|
||||
return {"success": True}
|
||||
12
CTFd/api/v1/statistics/__init__.py
Normal file
12
CTFd/api/v1/statistics/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from flask_restx import Namespace
|
||||
|
||||
statistics_namespace = Namespace(
|
||||
"statistics", description="Endpoint to retrieve Statistics"
|
||||
)
|
||||
|
||||
# isort:imports-firstparty
|
||||
from CTFd.api.v1.statistics import challenges # noqa: F401,I001
|
||||
from CTFd.api.v1.statistics import scores # noqa: F401
|
||||
from CTFd.api.v1.statistics import submissions # noqa: F401
|
||||
from CTFd.api.v1.statistics import teams # noqa: F401
|
||||
from CTFd.api.v1.statistics import users # noqa: F401
|
||||
137
CTFd/api/v1/statistics/challenges.py
Normal file
137
CTFd/api/v1/statistics/challenges.py
Normal file
@@ -0,0 +1,137 @@
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy import Integer, func
|
||||
from sqlalchemy.sql import and_
|
||||
from sqlalchemy.sql.expression import cast
|
||||
|
||||
from CTFd.api.v1.statistics import statistics_namespace
|
||||
from CTFd.models import Challenges, Solves, db
|
||||
from CTFd.utils.decorators import admins_only
|
||||
from CTFd.utils.modes import get_model
|
||||
|
||||
|
||||
@statistics_namespace.route("/challenges/<column>")
|
||||
class ChallengePropertyCounts(Resource):
|
||||
@admins_only
|
||||
def get(self, column):
|
||||
# TODO: Probably rename this function in CTFd 4.0 as it can be used to do more than just counts now
|
||||
funcs = {
|
||||
"count": func.count,
|
||||
"sum": func.sum,
|
||||
}
|
||||
aggregate_func = funcs[request.args.get("function", "count")]
|
||||
if column in Challenges.__table__.columns.keys():
|
||||
c1 = getattr(Challenges, column)
|
||||
c2 = getattr(
|
||||
Challenges, request.args.get("target", "category"), Challenges.category
|
||||
)
|
||||
# We cast this to Integer to deal with cases where SQLAlchemy will give us a Decimal instead
|
||||
data = (
|
||||
Challenges.query.with_entities(c1, cast(aggregate_func(c2), Integer))
|
||||
.group_by(c1)
|
||||
.all()
|
||||
)
|
||||
return {"success": True, "data": dict(data)}
|
||||
else:
|
||||
response = {"message": "That could not be found"}, 404
|
||||
return response
|
||||
|
||||
|
||||
@statistics_namespace.route("/challenges/solves")
|
||||
class ChallengeSolveStatistics(Resource):
|
||||
@admins_only
|
||||
def get(self):
|
||||
chals = (
|
||||
Challenges.query.filter(
|
||||
and_(Challenges.state != "hidden", Challenges.state != "locked")
|
||||
)
|
||||
.order_by(Challenges.value)
|
||||
.all()
|
||||
)
|
||||
|
||||
Model = get_model()
|
||||
|
||||
solves_sub = (
|
||||
db.session.query(
|
||||
Solves.challenge_id, db.func.count(Solves.challenge_id).label("solves")
|
||||
)
|
||||
.join(Model, Solves.account_id == Model.id)
|
||||
.filter(Model.banned == False, Model.hidden == False)
|
||||
.group_by(Solves.challenge_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
solves = (
|
||||
db.session.query(
|
||||
solves_sub.columns.challenge_id,
|
||||
solves_sub.columns.solves,
|
||||
Challenges.name,
|
||||
)
|
||||
.join(Challenges, solves_sub.columns.challenge_id == Challenges.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
response = []
|
||||
has_solves = []
|
||||
|
||||
for challenge_id, count, name in solves:
|
||||
challenge = {"id": challenge_id, "name": name, "solves": count}
|
||||
response.append(challenge)
|
||||
has_solves.append(challenge_id)
|
||||
for c in chals:
|
||||
if c.id not in has_solves:
|
||||
challenge = {"id": c.id, "name": c.name, "solves": 0}
|
||||
response.append(challenge)
|
||||
|
||||
db.session.close()
|
||||
return {"success": True, "data": response}
|
||||
|
||||
|
||||
@statistics_namespace.route("/challenges/solves/percentages")
|
||||
class ChallengeSolvePercentages(Resource):
|
||||
@admins_only
|
||||
def get(self):
|
||||
challenges = (
|
||||
Challenges.query.add_columns(
|
||||
Challenges.id,
|
||||
Challenges.name,
|
||||
Challenges.state,
|
||||
Challenges.max_attempts,
|
||||
)
|
||||
.order_by(Challenges.value)
|
||||
.all()
|
||||
)
|
||||
|
||||
Model = get_model()
|
||||
|
||||
teams_with_points = (
|
||||
db.session.query(Solves.account_id)
|
||||
.join(Model)
|
||||
.filter(Model.banned == False, Model.hidden == False)
|
||||
.group_by(Solves.account_id)
|
||||
.count()
|
||||
)
|
||||
|
||||
percentage_data = []
|
||||
for challenge in challenges:
|
||||
solve_count = (
|
||||
Solves.query.join(Model, Solves.account_id == Model.id)
|
||||
.filter(
|
||||
Solves.challenge_id == challenge.id,
|
||||
Model.banned == False,
|
||||
Model.hidden == False,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
if teams_with_points > 0:
|
||||
percentage = float(solve_count) / float(teams_with_points)
|
||||
else:
|
||||
percentage = 0.0
|
||||
|
||||
percentage_data.append(
|
||||
{"id": challenge.id, "name": challenge.name, "percentage": percentage}
|
||||
)
|
||||
|
||||
response = sorted(percentage_data, key=lambda x: x["percentage"], reverse=True)
|
||||
return {"success": True, "data": response}
|
||||
43
CTFd/api/v1/statistics/scores.py
Normal file
43
CTFd/api/v1/statistics/scores.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from collections import defaultdict
|
||||
|
||||
from flask_restx import Resource
|
||||
|
||||
from CTFd.api.v1.statistics import statistics_namespace
|
||||
from CTFd.models import Challenges, db
|
||||
from CTFd.utils.decorators import admins_only
|
||||
from CTFd.utils.scores import get_standings
|
||||
|
||||
|
||||
@statistics_namespace.route("/scores/distribution")
|
||||
class ScoresDistribution(Resource):
|
||||
@admins_only
|
||||
def get(self):
|
||||
challenge_count = Challenges.query.count() or 1
|
||||
total_points = (
|
||||
Challenges.query.with_entities(db.func.sum(Challenges.value).label("sum"))
|
||||
.filter_by(state="visible")
|
||||
.first()
|
||||
.sum
|
||||
) or 0
|
||||
# Convert Decimal() to int in some database backends for Python 2
|
||||
total_points = int(total_points)
|
||||
|
||||
# Divide score by challenges to get brackets with explicit floor division
|
||||
bracket_size = total_points // challenge_count
|
||||
|
||||
# Get standings
|
||||
standings = get_standings(admin=True)
|
||||
|
||||
# Iterate over standings and increment the count for each bracket for each standing within that bracket
|
||||
bottom, top = 0, bracket_size
|
||||
count = 1
|
||||
brackets = defaultdict(lambda: 0)
|
||||
for t in reversed(standings):
|
||||
if ((t.score >= bottom) and (t.score <= top)) or t.score <= 0:
|
||||
brackets[top] += 1
|
||||
else:
|
||||
count += 1
|
||||
bottom, top = (bracket_size, (bracket_size * count))
|
||||
brackets[top] += 1
|
||||
|
||||
return {"success": True, "data": {"brackets": brackets}}
|
||||
23
CTFd/api/v1/statistics/submissions.py
Normal file
23
CTFd/api/v1/statistics/submissions.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy import func
|
||||
|
||||
from CTFd.api.v1.statistics import statistics_namespace
|
||||
from CTFd.models import Submissions
|
||||
from CTFd.utils.decorators import admins_only
|
||||
|
||||
|
||||
@statistics_namespace.route("/submissions/<column>")
|
||||
class SubmissionPropertyCounts(Resource):
|
||||
@admins_only
|
||||
def get(self, column):
|
||||
if column in Submissions.__table__.columns.keys():
|
||||
prop = getattr(Submissions, column)
|
||||
data = (
|
||||
Submissions.query.with_entities(prop, func.count(prop))
|
||||
.group_by(prop)
|
||||
.all()
|
||||
)
|
||||
return {"success": True, "data": dict(data)}
|
||||
else:
|
||||
response = {"success": False, "errors": "That could not be found"}, 404
|
||||
return response
|
||||
14
CTFd/api/v1/statistics/teams.py
Normal file
14
CTFd/api/v1/statistics/teams.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from flask_restx import Resource
|
||||
|
||||
from CTFd.api.v1.statistics import statistics_namespace
|
||||
from CTFd.models import Teams
|
||||
from CTFd.utils.decorators import admins_only
|
||||
|
||||
|
||||
@statistics_namespace.route("/teams")
|
||||
class TeamStatistics(Resource):
|
||||
@admins_only
|
||||
def get(self):
|
||||
registered = Teams.query.count()
|
||||
data = {"registered": registered}
|
||||
return {"success": True, "data": data}
|
||||
30
CTFd/api/v1/statistics/users.py
Normal file
30
CTFd/api/v1/statistics/users.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy import func
|
||||
|
||||
from CTFd.api.v1.statistics import statistics_namespace
|
||||
from CTFd.models import Users
|
||||
from CTFd.utils.decorators import admins_only
|
||||
|
||||
|
||||
@statistics_namespace.route("/users")
|
||||
class UserStatistics(Resource):
|
||||
@admins_only
|
||||
def get(self):
|
||||
registered = Users.query.count()
|
||||
confirmed = Users.query.filter_by(verified=True).count()
|
||||
data = {"registered": registered, "confirmed": confirmed}
|
||||
return {"success": True, "data": data}
|
||||
|
||||
|
||||
@statistics_namespace.route("/users/<column>")
|
||||
class UserPropertyCounts(Resource):
|
||||
@admins_only
|
||||
def get(self, column):
|
||||
if column in Users.__table__.columns.keys():
|
||||
prop = getattr(Users, column)
|
||||
data = (
|
||||
Users.query.with_entities(prop, func.count(prop)).group_by(prop).all()
|
||||
)
|
||||
return {"success": True, "data": dict(data)}
|
||||
else:
|
||||
return {"success": False, "message": "That could not be found"}, 404
|
||||
260
CTFd/api/v1/submissions.py
Normal file
260
CTFd/api/v1/submissions.py
Normal file
@@ -0,0 +1,260 @@
|
||||
from typing import List
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import (
|
||||
APIDetailedSuccessResponse,
|
||||
PaginatedAPIListSuccessResponse,
|
||||
)
|
||||
from CTFd.cache import cache, clear_challenges, clear_standings
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import Solves, Submissions, db
|
||||
from CTFd.schemas.submissions import SubmissionSchema
|
||||
from CTFd.utils.decorators import admins_only
|
||||
from CTFd.utils.helpers.models import build_model_filters
|
||||
|
||||
submissions_namespace = Namespace(
|
||||
"submissions", description="Endpoint to retrieve Submission"
|
||||
)
|
||||
|
||||
SubmissionModel = sqlalchemy_to_pydantic(Submissions)
|
||||
TransientSubmissionModel = sqlalchemy_to_pydantic(Submissions, exclude=["id"])
|
||||
|
||||
|
||||
class SubmissionDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: SubmissionModel
|
||||
|
||||
|
||||
class SubmissionListSuccessResponse(PaginatedAPIListSuccessResponse):
|
||||
data: List[SubmissionModel]
|
||||
|
||||
|
||||
submissions_namespace.schema_model(
|
||||
"SubmissionDetailedSuccessResponse", SubmissionDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
submissions_namespace.schema_model(
|
||||
"SubmissionListSuccessResponse", SubmissionListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@submissions_namespace.route("")
|
||||
class SubmissionsList(Resource):
|
||||
@admins_only
|
||||
@submissions_namespace.doc(
|
||||
description="Endpoint to get submission objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "SubmissionListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"challenge_id": (int, None),
|
||||
"user_id": (int, None),
|
||||
"team_id": (int, None),
|
||||
"ip": (str, None),
|
||||
"provided": (str, None),
|
||||
"type": (str, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum(
|
||||
"SubmissionFields",
|
||||
{
|
||||
"challenge_id": "challenge_id",
|
||||
"user_id": "user_id",
|
||||
"team_id": "team_id",
|
||||
"ip": "ip",
|
||||
"provided": "provided",
|
||||
"type": "type",
|
||||
},
|
||||
),
|
||||
None,
|
||||
),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Submissions, query=q, field=field)
|
||||
|
||||
args = query_args
|
||||
schema = SubmissionSchema(many=True)
|
||||
|
||||
submissions = (
|
||||
Submissions.query.filter_by(**args)
|
||||
.filter(*filters)
|
||||
.paginate(max_per_page=100, error_out=False)
|
||||
)
|
||||
|
||||
response = schema.dump(submissions.items)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {
|
||||
"meta": {
|
||||
"pagination": {
|
||||
"page": submissions.page,
|
||||
"next": submissions.next_num,
|
||||
"prev": submissions.prev_num,
|
||||
"pages": submissions.pages,
|
||||
"per_page": submissions.per_page,
|
||||
"total": submissions.total,
|
||||
}
|
||||
},
|
||||
"success": True,
|
||||
"data": response.data,
|
||||
}
|
||||
|
||||
@admins_only
|
||||
@submissions_namespace.doc(
|
||||
description="Endpoint to create a submission object. Users should interact with the attempt endpoint to submit flags.",
|
||||
responses={
|
||||
200: ("Success", "SubmissionListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(TransientSubmissionModel, location="json")
|
||||
def post(self, json_args):
|
||||
req = json_args
|
||||
Model = Submissions.get_child(type=req.get("type"))
|
||||
schema = SubmissionSchema(instance=Model())
|
||||
response = schema.load(req)
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
db.session.add(response.data)
|
||||
db.session.commit()
|
||||
|
||||
response = schema.dump(response.data)
|
||||
db.session.close()
|
||||
|
||||
# Delete standings cache
|
||||
clear_standings()
|
||||
# Delete challenges cache
|
||||
clear_challenges()
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
|
||||
@submissions_namespace.route("/<submission_id>")
|
||||
@submissions_namespace.param("submission_id", "A Submission ID")
|
||||
class Submission(Resource):
|
||||
@admins_only
|
||||
@submissions_namespace.doc(
|
||||
description="Endpoint to get a submission object",
|
||||
responses={
|
||||
200: ("Success", "SubmissionDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, submission_id):
|
||||
submission = Submissions.query.filter_by(id=submission_id).first_or_404()
|
||||
schema = SubmissionSchema()
|
||||
response = schema.dump(submission)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@submissions_namespace.doc(
|
||||
description="Endpoint to edit a submission object",
|
||||
responses={
|
||||
200: ("Success", "SubmissionDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def patch(self, submission_id):
|
||||
submission = Submissions.query.filter_by(id=submission_id).first_or_404()
|
||||
|
||||
req = request.get_json()
|
||||
submission_type = req.get("type")
|
||||
|
||||
if submission_type == "correct":
|
||||
existing_solve = Solves.query.filter_by(
|
||||
challenge_id=submission.challenge_id,
|
||||
user_id=submission.user_id,
|
||||
team_id=submission.team_id if submission.team_id else None,
|
||||
).first()
|
||||
|
||||
# If a solve for this user / team pair exists, don't create a new solve
|
||||
if existing_solve:
|
||||
return {
|
||||
"success": False,
|
||||
"errors": {"type": ["Solve already exists for this submission"]},
|
||||
}, 400
|
||||
|
||||
solve = Solves(
|
||||
user_id=submission.user_id,
|
||||
challenge_id=submission.challenge_id,
|
||||
team_id=submission.team_id,
|
||||
ip=submission.ip,
|
||||
provided=submission.provided,
|
||||
date=submission.date,
|
||||
)
|
||||
db.session.add(solve)
|
||||
submission.type = "discard"
|
||||
db.session.commit()
|
||||
|
||||
# Delete standings cache
|
||||
clear_standings()
|
||||
clear_challenges()
|
||||
|
||||
submission = solve
|
||||
|
||||
schema = SubmissionSchema()
|
||||
response = schema.dump(submission)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@submissions_namespace.doc(
|
||||
description="Endpoint to delete a submission object",
|
||||
responses={
|
||||
200: ("Success", "APISimpleSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def delete(self, submission_id):
|
||||
submission = Submissions.query.filter_by(id=submission_id).first_or_404()
|
||||
account_id = submission.account_id
|
||||
challenge_id = submission.challenge_id
|
||||
db.session.delete(submission)
|
||||
db.session.commit()
|
||||
db.session.close()
|
||||
|
||||
# Clear out the user's recent attempt count
|
||||
# This is a little lossy but I don't think it matters in practice
|
||||
acc_kpm_key = f"account_kpm_{account_id}_{challenge_id}"
|
||||
cache.expire(acc_kpm_key, 0)
|
||||
|
||||
# Delete standings cache
|
||||
clear_standings()
|
||||
clear_challenges()
|
||||
|
||||
return {"success": True}
|
||||
166
CTFd/api/v1/tags.py
Normal file
166
CTFd/api/v1/tags.py
Normal file
@@ -0,0 +1,166 @@
|
||||
from typing import List
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import Tags, db
|
||||
from CTFd.schemas.tags import TagSchema
|
||||
from CTFd.utils.decorators import admins_only
|
||||
from CTFd.utils.helpers.models import build_model_filters
|
||||
|
||||
tags_namespace = Namespace("tags", description="Endpoint to retrieve Tags")
|
||||
|
||||
TagModel = sqlalchemy_to_pydantic(Tags)
|
||||
|
||||
|
||||
class TagDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: TagModel
|
||||
|
||||
|
||||
class TagListSuccessResponse(APIListSuccessResponse):
|
||||
data: List[TagModel]
|
||||
|
||||
|
||||
tags_namespace.schema_model(
|
||||
"TagDetailedSuccessResponse", TagDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
tags_namespace.schema_model("TagListSuccessResponse", TagListSuccessResponse.apidoc())
|
||||
|
||||
|
||||
@tags_namespace.route("")
|
||||
class TagList(Resource):
|
||||
@admins_only
|
||||
@tags_namespace.doc(
|
||||
description="Endpoint to list Tag objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "TagListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"challenge_id": (int, None),
|
||||
"value": (str, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum(
|
||||
"TagFields", {"challenge_id": "challenge_id", "value": "value"}
|
||||
),
|
||||
None,
|
||||
),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Tags, query=q, field=field)
|
||||
|
||||
tags = Tags.query.filter_by(**query_args).filter(*filters).all()
|
||||
schema = TagSchema(many=True)
|
||||
response = schema.dump(tags)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@tags_namespace.doc(
|
||||
description="Endpoint to create a Tag object",
|
||||
responses={
|
||||
200: ("Success", "TagDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
schema = TagSchema()
|
||||
response = schema.load(req, session=db.session)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
db.session.add(response.data)
|
||||
db.session.commit()
|
||||
|
||||
response = schema.dump(response.data)
|
||||
db.session.close()
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
|
||||
@tags_namespace.route("/<tag_id>")
|
||||
@tags_namespace.param("tag_id", "A Tag ID")
|
||||
class Tag(Resource):
|
||||
@admins_only
|
||||
@tags_namespace.doc(
|
||||
description="Endpoint to get a specific Tag object",
|
||||
responses={
|
||||
200: ("Success", "TagDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, tag_id):
|
||||
tag = Tags.query.filter_by(id=tag_id).first_or_404()
|
||||
|
||||
response = TagSchema().dump(tag)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@tags_namespace.doc(
|
||||
description="Endpoint to edit a specific Tag object",
|
||||
responses={
|
||||
200: ("Success", "TagDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def patch(self, tag_id):
|
||||
tag = Tags.query.filter_by(id=tag_id).first_or_404()
|
||||
schema = TagSchema()
|
||||
req = request.get_json()
|
||||
|
||||
response = schema.load(req, session=db.session, instance=tag)
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
db.session.commit()
|
||||
|
||||
response = schema.dump(response.data)
|
||||
db.session.close()
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@tags_namespace.doc(
|
||||
description="Endpoint to delete a specific Tag object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self, tag_id):
|
||||
tag = Tags.query.filter_by(id=tag_id).first_or_404()
|
||||
db.session.delete(tag)
|
||||
db.session.commit()
|
||||
db.session.close()
|
||||
|
||||
return {"success": True}
|
||||
669
CTFd/api/v1/teams.py
Normal file
669
CTFd/api/v1/teams.py
Normal file
@@ -0,0 +1,669 @@
|
||||
import copy
|
||||
from typing import List
|
||||
|
||||
from flask import abort, request, session
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import (
|
||||
APIDetailedSuccessResponse,
|
||||
PaginatedAPIListSuccessResponse,
|
||||
)
|
||||
from CTFd.cache import (
|
||||
clear_challenges,
|
||||
clear_standings,
|
||||
clear_team_session,
|
||||
clear_user_session,
|
||||
)
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import Awards, Submissions, Teams, Unlocks, Users, db
|
||||
from CTFd.schemas.awards import AwardSchema
|
||||
from CTFd.schemas.submissions import SubmissionSchema
|
||||
from CTFd.schemas.teams import TeamSchema
|
||||
from CTFd.utils import get_config
|
||||
from CTFd.utils.decorators import admins_only, authed_only, require_team
|
||||
from CTFd.utils.decorators.modes import require_team_mode
|
||||
from CTFd.utils.decorators.visibility import (
|
||||
check_account_visibility,
|
||||
check_score_visibility,
|
||||
)
|
||||
from CTFd.utils.helpers.models import build_model_filters
|
||||
from CTFd.utils.user import get_current_team, get_current_user_type, is_admin
|
||||
|
||||
teams_namespace = Namespace("teams", description="Endpoint to retrieve Teams")
|
||||
|
||||
TeamModel = sqlalchemy_to_pydantic(Teams)
|
||||
TransientTeamModel = sqlalchemy_to_pydantic(Teams, exclude=["id"])
|
||||
|
||||
|
||||
class TeamDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: TeamModel
|
||||
|
||||
|
||||
class TeamListSuccessResponse(PaginatedAPIListSuccessResponse):
|
||||
data: List[TeamModel]
|
||||
|
||||
|
||||
teams_namespace.schema_model(
|
||||
"TeamDetailedSuccessResponse", TeamDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
teams_namespace.schema_model(
|
||||
"TeamListSuccessResponse", TeamListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@teams_namespace.route("")
|
||||
class TeamList(Resource):
|
||||
method_decorators = [require_team_mode]
|
||||
|
||||
@check_account_visibility
|
||||
@teams_namespace.doc(
|
||||
description="Endpoint to get Team objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "TeamListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"affiliation": (str, None),
|
||||
"country": (str, None),
|
||||
"bracket": (str, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum(
|
||||
"TeamFields",
|
||||
{
|
||||
"name": "name",
|
||||
"website": "website",
|
||||
"country": "country",
|
||||
"bracket": "bracket",
|
||||
"affiliation": "affiliation",
|
||||
"email": "email",
|
||||
},
|
||||
),
|
||||
None,
|
||||
),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
|
||||
if field == "email":
|
||||
if is_admin() is False:
|
||||
return {
|
||||
"success": False,
|
||||
"errors": {"field": "Emails can only be queried by admins"},
|
||||
}, 400
|
||||
|
||||
filters = build_model_filters(model=Teams, query=q, field=field)
|
||||
|
||||
if is_admin() and request.args.get("view") == "admin":
|
||||
teams = (
|
||||
Teams.query.filter_by(**query_args)
|
||||
.filter(*filters)
|
||||
.paginate(per_page=50, max_per_page=100, error_out=False)
|
||||
)
|
||||
else:
|
||||
teams = (
|
||||
Teams.query.filter_by(hidden=False, banned=False, **query_args)
|
||||
.filter(*filters)
|
||||
.paginate(per_page=50, max_per_page=100, error_out=False)
|
||||
)
|
||||
|
||||
user_type = get_current_user_type(fallback="user")
|
||||
view = copy.deepcopy(TeamSchema.views.get(user_type))
|
||||
view.remove("members")
|
||||
response = TeamSchema(view=view, many=True).dump(teams.items)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {
|
||||
"meta": {
|
||||
"pagination": {
|
||||
"page": teams.page,
|
||||
"next": teams.next_num,
|
||||
"prev": teams.prev_num,
|
||||
"pages": teams.pages,
|
||||
"per_page": teams.per_page,
|
||||
"total": teams.total,
|
||||
}
|
||||
},
|
||||
"success": True,
|
||||
"data": response.data,
|
||||
}
|
||||
|
||||
@admins_only
|
||||
@teams_namespace.doc(
|
||||
description="Endpoint to create a Team object",
|
||||
responses={
|
||||
200: ("Success", "TeamDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
user_type = get_current_user_type()
|
||||
view = TeamSchema.views.get(user_type)
|
||||
schema = TeamSchema(view=view)
|
||||
response = schema.load(req)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
db.session.add(response.data)
|
||||
db.session.commit()
|
||||
|
||||
response = schema.dump(response.data)
|
||||
db.session.close()
|
||||
|
||||
clear_standings()
|
||||
clear_challenges()
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
|
||||
@teams_namespace.route("/<int:team_id>")
|
||||
@teams_namespace.param("team_id", "Team ID")
|
||||
class TeamPublic(Resource):
|
||||
method_decorators = [require_team_mode]
|
||||
|
||||
@check_account_visibility
|
||||
@teams_namespace.doc(
|
||||
description="Endpoint to get a specific Team object",
|
||||
responses={
|
||||
200: ("Success", "TeamDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, team_id):
|
||||
team = Teams.query.filter_by(id=team_id).first_or_404()
|
||||
|
||||
if (team.banned or team.hidden) and is_admin() is False:
|
||||
abort(404)
|
||||
|
||||
user_type = get_current_user_type(fallback="user")
|
||||
view = TeamSchema.views.get(user_type)
|
||||
schema = TeamSchema(view=view)
|
||||
response = schema.dump(team)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
response.data["place"] = team.place
|
||||
response.data["score"] = team.score
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@teams_namespace.doc(
|
||||
description="Endpoint to edit a specific Team object",
|
||||
responses={
|
||||
200: ("Success", "TeamDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def patch(self, team_id):
|
||||
team = Teams.query.filter_by(id=team_id).first_or_404()
|
||||
data = request.get_json()
|
||||
data["id"] = team_id
|
||||
|
||||
schema = TeamSchema(view="admin", instance=team, partial=True)
|
||||
|
||||
response = schema.load(data)
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
response = schema.dump(response.data)
|
||||
db.session.commit()
|
||||
|
||||
clear_team_session(team_id=team.id)
|
||||
clear_standings()
|
||||
clear_challenges()
|
||||
|
||||
db.session.close()
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@teams_namespace.doc(
|
||||
description="Endpoint to delete a specific Team object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self, team_id):
|
||||
team = Teams.query.filter_by(id=team_id).first_or_404()
|
||||
team_id = team.id
|
||||
|
||||
for member in team.members:
|
||||
member.team_id = None
|
||||
clear_user_session(user_id=member.id)
|
||||
|
||||
db.session.delete(team)
|
||||
db.session.commit()
|
||||
|
||||
clear_team_session(team_id=team_id)
|
||||
clear_standings()
|
||||
clear_challenges()
|
||||
|
||||
db.session.close()
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@teams_namespace.route("/me")
|
||||
@teams_namespace.param("team_id", "Current Team")
|
||||
class TeamPrivate(Resource):
|
||||
method_decorators = [require_team_mode]
|
||||
|
||||
@authed_only
|
||||
@require_team
|
||||
@teams_namespace.doc(
|
||||
description="Endpoint to get the current user's Team object",
|
||||
responses={
|
||||
200: ("Success", "TeamDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self):
|
||||
team = get_current_team()
|
||||
response = TeamSchema(view="self").dump(team)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
# A team can always calculate their score regardless of any setting because they can simply sum all of their challenges
|
||||
# Therefore a team requesting their private data should be able to get their own current score
|
||||
# However place is not something that a team can ascertain on their own so it is always gated behind freeze time
|
||||
response.data["place"] = team.place
|
||||
response.data["score"] = team.get_score(admin=True)
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@authed_only
|
||||
@require_team
|
||||
@teams_namespace.doc(
|
||||
description="Endpoint to edit the current user's Team object",
|
||||
responses={
|
||||
200: ("Success", "TeamDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def patch(self):
|
||||
team = get_current_team()
|
||||
if team.captain_id != session["id"]:
|
||||
return (
|
||||
{
|
||||
"success": False,
|
||||
"errors": {"": ["Only team captains can edit team information"]},
|
||||
},
|
||||
403,
|
||||
)
|
||||
|
||||
data = request.get_json()
|
||||
|
||||
response = TeamSchema(view="self", instance=team, partial=True).load(data)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
db.session.commit()
|
||||
clear_team_session(team_id=team.id)
|
||||
response = TeamSchema("self").dump(response.data)
|
||||
db.session.close()
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@authed_only
|
||||
@require_team
|
||||
@teams_namespace.doc(
|
||||
description="Endpoint to disband your current team. Can only be used if the team has performed no actions in the CTF.",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self):
|
||||
team_disbanding = get_config("team_disbanding", default="inactive_only")
|
||||
if team_disbanding == "disabled":
|
||||
return (
|
||||
{
|
||||
"success": False,
|
||||
"errors": {"": ["Team disbanding is currently disabled"]},
|
||||
},
|
||||
403,
|
||||
)
|
||||
|
||||
team = get_current_team()
|
||||
if team.captain_id != session["id"]:
|
||||
return (
|
||||
{
|
||||
"success": False,
|
||||
"errors": {"": ["Only team captains can disband their team"]},
|
||||
},
|
||||
403,
|
||||
)
|
||||
|
||||
# The team must not have performed any actions in the CTF
|
||||
performed_actions = any(
|
||||
[
|
||||
team.solves != [],
|
||||
team.fails != [],
|
||||
team.awards != [],
|
||||
Submissions.query.filter_by(team_id=team.id).all() != [],
|
||||
Unlocks.query.filter_by(team_id=team.id).all() != [],
|
||||
]
|
||||
)
|
||||
|
||||
if performed_actions:
|
||||
return (
|
||||
{
|
||||
"success": False,
|
||||
"errors": {
|
||||
"": [
|
||||
"You cannot disband your team as it has participated in the event. "
|
||||
"Please contact an admin to disband your team or remove a member."
|
||||
]
|
||||
},
|
||||
},
|
||||
403,
|
||||
)
|
||||
|
||||
for member in team.members:
|
||||
member.team_id = None
|
||||
clear_user_session(user_id=member.id)
|
||||
|
||||
db.session.delete(team)
|
||||
db.session.commit()
|
||||
|
||||
clear_team_session(team_id=team.id)
|
||||
clear_standings()
|
||||
clear_challenges()
|
||||
|
||||
db.session.close()
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@teams_namespace.route("/me/members")
|
||||
class TeamPrivateMembers(Resource):
|
||||
method_decorators = [require_team_mode]
|
||||
|
||||
@authed_only
|
||||
@require_team
|
||||
def post(self):
|
||||
team = get_current_team()
|
||||
if team.captain_id != session["id"]:
|
||||
return (
|
||||
{
|
||||
"success": False,
|
||||
"errors": {"": ["Only team captains can generate invite codes"]},
|
||||
},
|
||||
403,
|
||||
)
|
||||
|
||||
invite_code = team.get_invite_code()
|
||||
response = {"code": invite_code}
|
||||
return {"success": True, "data": response}
|
||||
|
||||
|
||||
@teams_namespace.route("/<team_id>/members")
|
||||
@teams_namespace.param("team_id", "Team ID")
|
||||
class TeamMembers(Resource):
|
||||
method_decorators = [require_team_mode]
|
||||
|
||||
@admins_only
|
||||
def get(self, team_id):
|
||||
team = Teams.query.filter_by(id=team_id).first_or_404()
|
||||
|
||||
view = "admin" if is_admin() else "user"
|
||||
schema = TeamSchema(view=view)
|
||||
response = schema.dump(team)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
members = response.data.get("members")
|
||||
|
||||
return {"success": True, "data": members}
|
||||
|
||||
@admins_only
|
||||
def post(self, team_id):
|
||||
team = Teams.query.filter_by(id=team_id).first_or_404()
|
||||
|
||||
# Generate an invite code if no user or body is specified
|
||||
if len(request.data) == 0:
|
||||
invite_code = team.get_invite_code()
|
||||
response = {"code": invite_code}
|
||||
return {"success": True, "data": response}
|
||||
|
||||
data = request.get_json()
|
||||
user_id = data.get("user_id")
|
||||
user = Users.query.filter_by(id=user_id).first_or_404()
|
||||
if user.team_id is None:
|
||||
team.members.append(user)
|
||||
db.session.commit()
|
||||
else:
|
||||
return (
|
||||
{
|
||||
"success": False,
|
||||
"errors": {"id": ["User has already joined a team"]},
|
||||
},
|
||||
400,
|
||||
)
|
||||
|
||||
view = "admin" if is_admin() else "user"
|
||||
schema = TeamSchema(view=view)
|
||||
response = schema.dump(team)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
members = response.data.get("members")
|
||||
|
||||
return {"success": True, "data": members}
|
||||
|
||||
@admins_only
|
||||
def delete(self, team_id):
|
||||
team = Teams.query.filter_by(id=team_id).first_or_404()
|
||||
|
||||
data = request.get_json()
|
||||
user_id = data["user_id"]
|
||||
user = Users.query.filter_by(id=user_id).first_or_404()
|
||||
|
||||
if user.team_id == team.id:
|
||||
team.members.remove(user)
|
||||
|
||||
# Remove information that links the user to the team
|
||||
Submissions.query.filter_by(user_id=user.id).delete()
|
||||
Awards.query.filter_by(user_id=user.id).delete()
|
||||
Unlocks.query.filter_by(user_id=user.id).delete()
|
||||
|
||||
db.session.commit()
|
||||
else:
|
||||
return (
|
||||
{"success": False, "errors": {"id": ["User is not part of this team"]}},
|
||||
400,
|
||||
)
|
||||
|
||||
view = "admin" if is_admin() else "user"
|
||||
schema = TeamSchema(view=view)
|
||||
response = schema.dump(team)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
members = response.data.get("members")
|
||||
|
||||
return {"success": True, "data": members}
|
||||
|
||||
|
||||
@teams_namespace.route("/me/solves")
|
||||
class TeamPrivateSolves(Resource):
|
||||
method_decorators = [require_team_mode]
|
||||
|
||||
@authed_only
|
||||
@require_team
|
||||
def get(self):
|
||||
team = get_current_team()
|
||||
solves = team.get_solves(admin=True)
|
||||
|
||||
view = "admin" if is_admin() else "user"
|
||||
schema = SubmissionSchema(view=view, many=True)
|
||||
response = schema.dump(solves)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
count = len(response.data)
|
||||
return {"success": True, "data": response.data, "meta": {"count": count}}
|
||||
|
||||
|
||||
@teams_namespace.route("/me/fails")
|
||||
class TeamPrivateFails(Resource):
|
||||
method_decorators = [require_team_mode]
|
||||
|
||||
@authed_only
|
||||
@require_team
|
||||
def get(self):
|
||||
team = get_current_team()
|
||||
fails = team.get_fails(admin=True)
|
||||
|
||||
view = "admin" if is_admin() else "user"
|
||||
|
||||
# We want to return the count purely for stats & graphs
|
||||
# but this data isn't really needed by the end user.
|
||||
# Only actually show fail data for admins.
|
||||
if is_admin():
|
||||
schema = SubmissionSchema(view=view, many=True)
|
||||
response = schema.dump(fails)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
data = response.data
|
||||
else:
|
||||
data = []
|
||||
count = len(fails)
|
||||
|
||||
return {"success": True, "data": data, "meta": {"count": count}}
|
||||
|
||||
|
||||
@teams_namespace.route("/me/awards")
|
||||
class TeamPrivateAwards(Resource):
|
||||
method_decorators = [require_team_mode]
|
||||
|
||||
@authed_only
|
||||
@require_team
|
||||
def get(self):
|
||||
team = get_current_team()
|
||||
awards = team.get_awards(admin=True)
|
||||
|
||||
schema = AwardSchema(many=True)
|
||||
response = schema.dump(awards)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
count = len(response.data)
|
||||
return {"success": True, "data": response.data, "meta": {"count": count}}
|
||||
|
||||
|
||||
@teams_namespace.route("/<team_id>/solves")
|
||||
@teams_namespace.param("team_id", "Team ID")
|
||||
class TeamPublicSolves(Resource):
|
||||
method_decorators = [require_team_mode]
|
||||
|
||||
@check_account_visibility
|
||||
@check_score_visibility
|
||||
def get(self, team_id):
|
||||
team = Teams.query.filter_by(id=team_id).first_or_404()
|
||||
|
||||
if (team.banned or team.hidden) and is_admin() is False:
|
||||
abort(404)
|
||||
solves = team.get_solves(admin=is_admin())
|
||||
|
||||
view = "admin" if is_admin() else "user"
|
||||
schema = SubmissionSchema(view=view, many=True)
|
||||
response = schema.dump(solves)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
count = len(response.data)
|
||||
return {"success": True, "data": response.data, "meta": {"count": count}}
|
||||
|
||||
|
||||
@teams_namespace.route("/<team_id>/fails")
|
||||
@teams_namespace.param("team_id", "Team ID")
|
||||
class TeamPublicFails(Resource):
|
||||
method_decorators = [require_team_mode]
|
||||
|
||||
@check_account_visibility
|
||||
@check_score_visibility
|
||||
def get(self, team_id):
|
||||
team = Teams.query.filter_by(id=team_id).first_or_404()
|
||||
|
||||
if (team.banned or team.hidden) and is_admin() is False:
|
||||
abort(404)
|
||||
fails = team.get_fails(admin=is_admin())
|
||||
|
||||
view = "admin" if is_admin() else "user"
|
||||
|
||||
# We want to return the count purely for stats & graphs
|
||||
# but this data isn't really needed by the end user.
|
||||
# Only actually show fail data for admins.
|
||||
if is_admin():
|
||||
schema = SubmissionSchema(view=view, many=True)
|
||||
response = schema.dump(fails)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
data = response.data
|
||||
else:
|
||||
data = []
|
||||
count = len(fails)
|
||||
|
||||
return {"success": True, "data": data, "meta": {"count": count}}
|
||||
|
||||
|
||||
@teams_namespace.route("/<team_id>/awards")
|
||||
@teams_namespace.param("team_id", "Team ID")
|
||||
class TeamPublicAwards(Resource):
|
||||
method_decorators = [require_team_mode]
|
||||
|
||||
@check_account_visibility
|
||||
@check_score_visibility
|
||||
def get(self, team_id):
|
||||
team = Teams.query.filter_by(id=team_id).first_or_404()
|
||||
|
||||
if (team.banned or team.hidden) and is_admin() is False:
|
||||
abort(404)
|
||||
awards = team.get_awards(admin=is_admin())
|
||||
|
||||
schema = AwardSchema(many=True)
|
||||
response = schema.dump(awards)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
count = len(response.data)
|
||||
return {"success": True, "data": response.data, "meta": {"count": count}}
|
||||
155
CTFd/api/v1/tokens.py
Normal file
155
CTFd/api/v1/tokens.py
Normal file
@@ -0,0 +1,155 @@
|
||||
import datetime
|
||||
from typing import List
|
||||
|
||||
from flask import request, session
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||
from CTFd.models import Tokens, db
|
||||
from CTFd.schemas.tokens import TokenSchema
|
||||
from CTFd.utils.decorators import authed_only, require_verified_emails
|
||||
from CTFd.utils.security.auth import generate_user_token
|
||||
from CTFd.utils.user import get_current_user, get_current_user_type, is_admin
|
||||
|
||||
tokens_namespace = Namespace("tokens", description="Endpoint to retrieve Tokens")
|
||||
|
||||
TokenModel = sqlalchemy_to_pydantic(Tokens)
|
||||
ValuelessTokenModel = sqlalchemy_to_pydantic(Tokens, exclude=["value"])
|
||||
|
||||
|
||||
class TokenDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: TokenModel
|
||||
|
||||
|
||||
class ValuelessTokenDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: ValuelessTokenModel
|
||||
|
||||
|
||||
class TokenListSuccessResponse(APIListSuccessResponse):
|
||||
data: List[TokenModel]
|
||||
|
||||
|
||||
tokens_namespace.schema_model(
|
||||
"TokenDetailedSuccessResponse", TokenDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
tokens_namespace.schema_model(
|
||||
"ValuelessTokenDetailedSuccessResponse",
|
||||
ValuelessTokenDetailedSuccessResponse.apidoc(),
|
||||
)
|
||||
|
||||
tokens_namespace.schema_model(
|
||||
"TokenListSuccessResponse", TokenListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@tokens_namespace.route("")
|
||||
class TokenList(Resource):
|
||||
@require_verified_emails
|
||||
@authed_only
|
||||
@tokens_namespace.doc(
|
||||
description="Endpoint to get token objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "TokenListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self):
|
||||
user = get_current_user()
|
||||
tokens = Tokens.query.filter_by(user_id=user.id)
|
||||
response = TokenSchema(view=["id", "type", "expiration"], many=True).dump(
|
||||
tokens
|
||||
)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@require_verified_emails
|
||||
@authed_only
|
||||
@tokens_namespace.doc(
|
||||
description="Endpoint to create a token object",
|
||||
responses={
|
||||
200: ("Success", "TokenDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
expiration = req.get("expiration")
|
||||
description = req.get("description")
|
||||
if expiration:
|
||||
expiration = datetime.datetime.strptime(expiration, "%Y-%m-%d")
|
||||
|
||||
user = get_current_user()
|
||||
token = generate_user_token(
|
||||
user, expiration=expiration, description=description
|
||||
)
|
||||
|
||||
# Explicitly use admin view so that user's can see the value of their token
|
||||
schema = TokenSchema(view="admin")
|
||||
response = schema.dump(token)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
|
||||
@tokens_namespace.route("/<token_id>")
|
||||
@tokens_namespace.param("token_id", "A Token ID")
|
||||
class TokenDetail(Resource):
|
||||
@require_verified_emails
|
||||
@authed_only
|
||||
@tokens_namespace.doc(
|
||||
description="Endpoint to get an existing token object",
|
||||
responses={
|
||||
200: ("Success", "ValuelessTokenDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, token_id):
|
||||
if is_admin():
|
||||
token = Tokens.query.filter_by(id=token_id).first_or_404()
|
||||
else:
|
||||
token = Tokens.query.filter_by(
|
||||
id=token_id, user_id=session["id"]
|
||||
).first_or_404()
|
||||
|
||||
user_type = get_current_user_type(fallback="user")
|
||||
schema = TokenSchema(view=user_type)
|
||||
response = schema.dump(token)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@require_verified_emails
|
||||
@authed_only
|
||||
@tokens_namespace.doc(
|
||||
description="Endpoint to delete an existing token object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self, token_id):
|
||||
if is_admin():
|
||||
token = Tokens.query.filter_by(id=token_id).first_or_404()
|
||||
else:
|
||||
user = get_current_user()
|
||||
token = Tokens.query.filter_by(id=token_id, user_id=user.id).first_or_404()
|
||||
db.session.delete(token)
|
||||
db.session.commit()
|
||||
db.session.close()
|
||||
|
||||
return {"success": True}
|
||||
181
CTFd/api/v1/topics.py
Normal file
181
CTFd/api/v1/topics.py
Normal file
@@ -0,0 +1,181 @@
|
||||
from typing import List
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import ChallengeTopics, Topics, db
|
||||
from CTFd.schemas.topics import ChallengeTopicSchema, TopicSchema
|
||||
from CTFd.utils.decorators import admins_only
|
||||
from CTFd.utils.helpers.models import build_model_filters
|
||||
|
||||
topics_namespace = Namespace("topics", description="Endpoint to retrieve Topics")
|
||||
|
||||
TopicModel = sqlalchemy_to_pydantic(Topics)
|
||||
|
||||
|
||||
class TopicDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: TopicModel
|
||||
|
||||
|
||||
class TopicListSuccessResponse(APIListSuccessResponse):
|
||||
data: List[TopicModel]
|
||||
|
||||
|
||||
topics_namespace.schema_model(
|
||||
"TopicDetailedSuccessResponse", TopicDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
topics_namespace.schema_model(
|
||||
"TopicListSuccessResponse", TopicListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@topics_namespace.route("")
|
||||
class TopicList(Resource):
|
||||
@admins_only
|
||||
@topics_namespace.doc(
|
||||
description="Endpoint to list Topic objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "TopicListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"value": (str, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum("TopicFields", {"value": "value"}),
|
||||
None,
|
||||
),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Topics, query=q, field=field)
|
||||
|
||||
topics = Topics.query.filter_by(**query_args).filter(*filters).all()
|
||||
schema = TopicSchema(many=True)
|
||||
response = schema.dump(topics)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@topics_namespace.doc(
|
||||
description="Endpoint to create a Topic object",
|
||||
responses={
|
||||
200: ("Success", "TopicDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
value = req.get("value")
|
||||
|
||||
if value:
|
||||
topic = Topics.query.filter_by(value=value).first()
|
||||
if topic is None:
|
||||
schema = TopicSchema()
|
||||
response = schema.load(req, session=db.session)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
topic = response.data
|
||||
db.session.add(topic)
|
||||
db.session.commit()
|
||||
else:
|
||||
topic_id = req.get("topic_id")
|
||||
topic = Topics.query.filter_by(id=topic_id).first_or_404()
|
||||
|
||||
req["topic_id"] = topic.id
|
||||
topic_type = req.get("type")
|
||||
if topic_type == "challenge":
|
||||
schema = ChallengeTopicSchema()
|
||||
response = schema.load(req, session=db.session)
|
||||
else:
|
||||
return {"success": False}, 400
|
||||
|
||||
db.session.add(response.data)
|
||||
db.session.commit()
|
||||
|
||||
response = schema.dump(response.data)
|
||||
db.session.close()
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@topics_namespace.doc(
|
||||
description="Endpoint to delete a specific Topic object of a specific type",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
@validate_args(
|
||||
{"type": (str, None), "target_id": (int, 0)},
|
||||
location="query",
|
||||
)
|
||||
def delete(self, query_args):
|
||||
topic_type = query_args.get("type")
|
||||
target_id = int(query_args.get("target_id", 0))
|
||||
|
||||
if topic_type == "challenge":
|
||||
Model = ChallengeTopics
|
||||
else:
|
||||
return {"success": False}, 400
|
||||
|
||||
topic = Model.query.filter_by(id=target_id).first_or_404()
|
||||
db.session.delete(topic)
|
||||
db.session.commit()
|
||||
db.session.close()
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@topics_namespace.route("/<topic_id>")
|
||||
class Topic(Resource):
|
||||
@admins_only
|
||||
@topics_namespace.doc(
|
||||
description="Endpoint to get a specific Topic object",
|
||||
responses={
|
||||
200: ("Success", "TopicDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, topic_id):
|
||||
topic = Topics.query.filter_by(id=topic_id).first_or_404()
|
||||
response = TopicSchema().dump(topic)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@topics_namespace.doc(
|
||||
description="Endpoint to delete a specific Topic object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self, topic_id):
|
||||
topic = Topics.query.filter_by(id=topic_id).first_or_404()
|
||||
db.session.delete(topic)
|
||||
db.session.commit()
|
||||
db.session.close()
|
||||
|
||||
return {"success": True}
|
||||
205
CTFd/api/v1/unlocks.py
Normal file
205
CTFd/api/v1/unlocks.py
Normal file
@@ -0,0 +1,205 @@
|
||||
from typing import List
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||
from CTFd.cache import clear_standings
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import Unlocks, db, get_class_by_tablename
|
||||
from CTFd.schemas.awards import AwardSchema
|
||||
from CTFd.schemas.unlocks import UnlockSchema
|
||||
from CTFd.utils.decorators import (
|
||||
admins_only,
|
||||
authed_only,
|
||||
during_ctf_time_only,
|
||||
require_verified_emails,
|
||||
)
|
||||
from CTFd.utils.helpers.models import build_model_filters
|
||||
from CTFd.utils.user import get_current_user
|
||||
|
||||
unlocks_namespace = Namespace("unlocks", description="Endpoint to retrieve Unlocks")
|
||||
|
||||
UnlockModel = sqlalchemy_to_pydantic(Unlocks)
|
||||
TransientUnlockModel = sqlalchemy_to_pydantic(Unlocks, exclude=["id"])
|
||||
|
||||
|
||||
class UnlockDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: UnlockModel
|
||||
|
||||
|
||||
class UnlockListSuccessResponse(APIListSuccessResponse):
|
||||
data: List[UnlockModel]
|
||||
|
||||
|
||||
unlocks_namespace.schema_model(
|
||||
"UnlockDetailedSuccessResponse", UnlockDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
unlocks_namespace.schema_model(
|
||||
"UnlockListSuccessResponse", UnlockListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@unlocks_namespace.route("")
|
||||
class UnlockList(Resource):
|
||||
@admins_only
|
||||
@unlocks_namespace.doc(
|
||||
description="Endpoint to get unlock objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "UnlockListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"user_id": (int, None),
|
||||
"team_id": (int, None),
|
||||
"target": (int, None),
|
||||
"type": (str, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum("UnlockFields", {"target": "target", "type": "type"}),
|
||||
None,
|
||||
),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Unlocks, query=q, field=field)
|
||||
|
||||
unlocks = Unlocks.query.filter_by(**query_args).filter(*filters).all()
|
||||
schema = UnlockSchema()
|
||||
response = schema.dump(unlocks)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@during_ctf_time_only
|
||||
@require_verified_emails
|
||||
@authed_only
|
||||
@unlocks_namespace.doc(
|
||||
description="Endpoint to create an unlock object. Used to unlock hints.",
|
||||
responses={
|
||||
200: ("Success", "UnlockDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
user = get_current_user()
|
||||
|
||||
target_type = req["type"]
|
||||
|
||||
req["user_id"] = user.id
|
||||
req["team_id"] = user.team_id
|
||||
|
||||
Model = get_class_by_tablename(req["type"])
|
||||
target = Model.query.filter_by(id=req["target"]).first_or_404()
|
||||
|
||||
if target_type == "hints":
|
||||
# We should use the team's score if in teams mode
|
||||
# user.account gives the appropriate account based on team mode
|
||||
# Use get_score with admin to get the account's full score value
|
||||
if target.cost > user.account.get_score(admin=True):
|
||||
return (
|
||||
{
|
||||
"success": False,
|
||||
"errors": {
|
||||
"score": "You do not have enough points to unlock this hint"
|
||||
},
|
||||
},
|
||||
400,
|
||||
)
|
||||
|
||||
schema = UnlockSchema()
|
||||
response = schema.load(req, session=db.session)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
# Search for an existing unlock that matches the target and type
|
||||
# And matches either the requesting user id or the requesting team id
|
||||
existing = Unlocks.query.filter(
|
||||
Unlocks.target == req["target"],
|
||||
Unlocks.type == req["type"],
|
||||
Unlocks.account_id == user.account_id,
|
||||
).first()
|
||||
if existing:
|
||||
return (
|
||||
{
|
||||
"success": False,
|
||||
"errors": {"target": "You've already unlocked this target"},
|
||||
},
|
||||
400,
|
||||
)
|
||||
|
||||
db.session.add(response.data)
|
||||
|
||||
award_schema = AwardSchema()
|
||||
award = {
|
||||
"user_id": user.id,
|
||||
"team_id": user.team_id,
|
||||
"name": target.name,
|
||||
"description": target.description,
|
||||
"value": (-target.cost),
|
||||
"category": target.category,
|
||||
}
|
||||
|
||||
award = award_schema.load(award)
|
||||
db.session.add(award.data)
|
||||
db.session.commit()
|
||||
clear_standings()
|
||||
|
||||
response = schema.dump(response.data)
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
elif target_type == "solutions":
|
||||
schema = UnlockSchema()
|
||||
response = schema.load(req, session=db.session)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
# Search for an existing unlock that matches the target and type
|
||||
# And matches either the requesting user id or the requesting team id
|
||||
existing = Unlocks.query.filter(
|
||||
Unlocks.target == req["target"],
|
||||
Unlocks.type == req["type"],
|
||||
Unlocks.account_id == user.account_id,
|
||||
).first()
|
||||
if existing:
|
||||
return (
|
||||
{
|
||||
"success": False,
|
||||
"errors": {"target": "You've already unlocked this target"},
|
||||
},
|
||||
400,
|
||||
)
|
||||
|
||||
db.session.add(response.data)
|
||||
db.session.commit()
|
||||
|
||||
response = schema.dump(response.data)
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
else:
|
||||
return (
|
||||
{
|
||||
"success": False,
|
||||
"errors": {"type": "Unknown target type"},
|
||||
},
|
||||
400,
|
||||
)
|
||||
537
CTFd/api/v1/users.py
Normal file
537
CTFd/api/v1/users.py
Normal file
@@ -0,0 +1,537 @@
|
||||
from typing import List
|
||||
|
||||
from flask import abort, request, session
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import (
|
||||
APIDetailedSuccessResponse,
|
||||
PaginatedAPIListSuccessResponse,
|
||||
)
|
||||
from CTFd.cache import clear_challenges, clear_standings, clear_user_session
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import (
|
||||
Awards,
|
||||
Notifications,
|
||||
Solves,
|
||||
Submissions,
|
||||
Tracking,
|
||||
Unlocks,
|
||||
Users,
|
||||
db,
|
||||
)
|
||||
from CTFd.schemas.awards import AwardSchema
|
||||
from CTFd.schemas.submissions import SubmissionSchema
|
||||
from CTFd.schemas.users import UserSchema
|
||||
from CTFd.utils.challenges import get_submissions_for_user_id_for_challenge_id
|
||||
from CTFd.utils.config import get_config, get_mail_provider
|
||||
from CTFd.utils.decorators import admins_only, authed_only, ratelimit
|
||||
from CTFd.utils.decorators.visibility import (
|
||||
check_account_visibility,
|
||||
check_score_visibility,
|
||||
)
|
||||
from CTFd.utils.email import sendmail, user_created_notification
|
||||
from CTFd.utils.helpers.models import build_model_filters
|
||||
from CTFd.utils.security.auth import update_user
|
||||
from CTFd.utils.user import get_current_user, get_current_user_type, is_admin
|
||||
|
||||
users_namespace = Namespace("users", description="Endpoint to retrieve Users")
|
||||
|
||||
|
||||
UserModel = sqlalchemy_to_pydantic(Users)
|
||||
TransientUserModel = sqlalchemy_to_pydantic(Users, exclude=["id"])
|
||||
|
||||
|
||||
class UserDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: UserModel
|
||||
|
||||
|
||||
class UserListSuccessResponse(PaginatedAPIListSuccessResponse):
|
||||
data: List[UserModel]
|
||||
|
||||
|
||||
users_namespace.schema_model(
|
||||
"UserDetailedSuccessResponse", UserDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
users_namespace.schema_model(
|
||||
"UserListSuccessResponse", UserListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@users_namespace.route("")
|
||||
class UserList(Resource):
|
||||
@check_account_visibility
|
||||
@users_namespace.doc(
|
||||
description="Endpoint to get User objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "UserListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"affiliation": (str, None),
|
||||
"country": (str, None),
|
||||
"bracket": (str, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum(
|
||||
"UserFields",
|
||||
{
|
||||
"name": "name",
|
||||
"website": "website",
|
||||
"country": "country",
|
||||
"bracket": "bracket",
|
||||
"affiliation": "affiliation",
|
||||
"email": "email",
|
||||
},
|
||||
),
|
||||
None,
|
||||
),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
|
||||
if field == "email":
|
||||
if is_admin() is False:
|
||||
return {
|
||||
"success": False,
|
||||
"errors": {"field": "Emails can only be queried by admins"},
|
||||
}, 400
|
||||
|
||||
filters = build_model_filters(model=Users, query=q, field=field)
|
||||
|
||||
if is_admin() and request.args.get("view") == "admin":
|
||||
users = (
|
||||
Users.query.filter_by(**query_args)
|
||||
.filter(*filters)
|
||||
.paginate(per_page=50, max_per_page=100, error_out=False)
|
||||
)
|
||||
else:
|
||||
users = (
|
||||
Users.query.filter_by(banned=False, hidden=False, **query_args)
|
||||
.filter(*filters)
|
||||
.paginate(per_page=50, max_per_page=100, error_out=False)
|
||||
)
|
||||
|
||||
response = UserSchema(view="user", many=True).dump(users.items)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {
|
||||
"meta": {
|
||||
"pagination": {
|
||||
"page": users.page,
|
||||
"next": users.next_num,
|
||||
"prev": users.prev_num,
|
||||
"pages": users.pages,
|
||||
"per_page": users.per_page,
|
||||
"total": users.total,
|
||||
}
|
||||
},
|
||||
"success": True,
|
||||
"data": response.data,
|
||||
}
|
||||
|
||||
@admins_only
|
||||
@users_namespace.doc(
|
||||
description="Endpoint to create a User object",
|
||||
responses={
|
||||
200: ("Success", "UserDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
params={
|
||||
"notify": "Whether to send the created user an email with their credentials"
|
||||
},
|
||||
)
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
schema = UserSchema("admin")
|
||||
response = schema.load(req)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
db.session.add(response.data)
|
||||
db.session.commit()
|
||||
|
||||
if request.args.get("notify"):
|
||||
name = response.data.name
|
||||
email = response.data.email
|
||||
password = req.get("password")
|
||||
|
||||
user_created_notification(addr=email, name=name, password=password)
|
||||
|
||||
clear_standings()
|
||||
clear_challenges()
|
||||
|
||||
response = schema.dump(response.data)
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
|
||||
@users_namespace.route("/<int:user_id>")
|
||||
@users_namespace.param("user_id", "User ID")
|
||||
class UserPublic(Resource):
|
||||
@check_account_visibility
|
||||
@users_namespace.doc(
|
||||
description="Endpoint to get a specific User object",
|
||||
responses={
|
||||
200: ("Success", "UserDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, user_id):
|
||||
user = Users.query.filter_by(id=user_id).first_or_404()
|
||||
|
||||
if (user.banned or user.hidden) and is_admin() is False:
|
||||
abort(404)
|
||||
|
||||
user_type = get_current_user_type(fallback="user")
|
||||
response = UserSchema(view=user_type).dump(user)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
response.data["place"] = user.place
|
||||
response.data["score"] = user.score
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@users_namespace.doc(
|
||||
description="Endpoint to edit a specific User object",
|
||||
responses={
|
||||
200: ("Success", "UserDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def patch(self, user_id):
|
||||
user = Users.query.filter_by(id=user_id).first_or_404()
|
||||
data = request.get_json()
|
||||
data["id"] = user_id
|
||||
|
||||
# Admins should not be able to ban themselves
|
||||
if data["id"] == session["id"] and (
|
||||
data.get("banned") is True or data.get("banned") == "true"
|
||||
):
|
||||
return (
|
||||
{"success": False, "errors": {"id": "You cannot ban yourself"}},
|
||||
400,
|
||||
)
|
||||
|
||||
schema = UserSchema(view="admin", instance=user, partial=True)
|
||||
response = schema.load(data)
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
# This generates the response first before actually changing the type
|
||||
# This avoids an error during User type changes where we change
|
||||
# the polymorphic identity resulting in an ObjectDeletedError
|
||||
# https://github.com/CTFd/CTFd/issues/1794
|
||||
response = schema.dump(response.data)
|
||||
db.session.commit()
|
||||
db.session.close()
|
||||
|
||||
clear_user_session(user_id=user_id)
|
||||
clear_standings()
|
||||
clear_challenges()
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@users_namespace.doc(
|
||||
description="Endpoint to delete a specific User object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self, user_id):
|
||||
# Admins should not be able to delete themselves
|
||||
if user_id == session["id"]:
|
||||
return (
|
||||
{"success": False, "errors": {"id": "You cannot delete yourself"}},
|
||||
400,
|
||||
)
|
||||
|
||||
Notifications.query.filter_by(user_id=user_id).delete()
|
||||
Awards.query.filter_by(user_id=user_id).delete()
|
||||
Unlocks.query.filter_by(user_id=user_id).delete()
|
||||
Submissions.query.filter_by(user_id=user_id).delete()
|
||||
Solves.query.filter_by(user_id=user_id).delete()
|
||||
Tracking.query.filter_by(user_id=user_id).delete()
|
||||
Users.query.filter_by(id=user_id).delete()
|
||||
db.session.commit()
|
||||
db.session.close()
|
||||
|
||||
clear_user_session(user_id=user_id)
|
||||
clear_standings()
|
||||
clear_challenges()
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@users_namespace.route("/me")
|
||||
class UserPrivate(Resource):
|
||||
@authed_only
|
||||
@users_namespace.doc(
|
||||
description="Endpoint to get the User object for the current user",
|
||||
responses={
|
||||
200: ("Success", "UserDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self):
|
||||
user = get_current_user()
|
||||
response = UserSchema("self").dump(user).data
|
||||
|
||||
# A user can always calculate their score regardless of any setting because they can simply sum all of their challenges
|
||||
# Therefore a user requesting their private data should be able to get their own current score
|
||||
# However place is not something that a user can ascertain on their own so it is always gated behind freeze time
|
||||
response["place"] = user.place
|
||||
response["score"] = user.get_score(admin=True)
|
||||
|
||||
return {"success": True, "data": response}
|
||||
|
||||
@authed_only
|
||||
@users_namespace.doc(
|
||||
description="Endpoint to edit the User object for the current user",
|
||||
responses={
|
||||
200: ("Success", "UserDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def patch(self):
|
||||
user = get_current_user()
|
||||
data = request.get_json()
|
||||
schema = UserSchema(view="self", instance=user, partial=True)
|
||||
response = schema.load(data)
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Update user's session for the new session hash
|
||||
update_user(user)
|
||||
|
||||
response = schema.dump(response.data)
|
||||
db.session.close()
|
||||
|
||||
clear_standings()
|
||||
clear_challenges()
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
|
||||
@users_namespace.route("/me/submissions")
|
||||
class UserPrivateSubmissions(Resource):
|
||||
@authed_only
|
||||
def get(self):
|
||||
# TODO: CTFd 4.0 Self viewing submissions should not be enabled by default until further notice
|
||||
if bool(get_config("view_self_submissions")) is False:
|
||||
abort(403)
|
||||
user = get_current_user()
|
||||
challenge_id = request.args.get("challenge_id")
|
||||
response = get_submissions_for_user_id_for_challenge_id(
|
||||
user_id=user.id, challenge_id=challenge_id
|
||||
)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
count = len(response.data)
|
||||
return {"success": True, "data": response.data, "meta": {"count": count}}
|
||||
|
||||
|
||||
@users_namespace.route("/me/solves")
|
||||
class UserPrivateSolves(Resource):
|
||||
@authed_only
|
||||
def get(self):
|
||||
user = get_current_user()
|
||||
solves = user.get_solves(admin=True)
|
||||
|
||||
view = "user" if not is_admin() else "admin"
|
||||
response = SubmissionSchema(view=view, many=True).dump(solves)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
count = len(response.data)
|
||||
return {"success": True, "data": response.data, "meta": {"count": count}}
|
||||
|
||||
|
||||
@users_namespace.route("/me/fails")
|
||||
class UserPrivateFails(Resource):
|
||||
@authed_only
|
||||
def get(self):
|
||||
user = get_current_user()
|
||||
fails = user.get_fails(admin=True)
|
||||
|
||||
view = "user" if not is_admin() else "admin"
|
||||
|
||||
# We want to return the count purely for stats & graphs
|
||||
# but this data isn't really needed by the end user.
|
||||
# Only actually show fail data for admins.
|
||||
if is_admin():
|
||||
response = SubmissionSchema(view=view, many=True).dump(fails)
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
data = response.data
|
||||
else:
|
||||
data = []
|
||||
|
||||
count = len(fails)
|
||||
return {"success": True, "data": data, "meta": {"count": count}}
|
||||
|
||||
|
||||
@users_namespace.route("/me/awards")
|
||||
@users_namespace.param("user_id", "User ID")
|
||||
class UserPrivateAwards(Resource):
|
||||
@authed_only
|
||||
def get(self):
|
||||
user = get_current_user()
|
||||
awards = user.get_awards(admin=True)
|
||||
|
||||
view = "user" if not is_admin() else "admin"
|
||||
response = AwardSchema(view=view, many=True).dump(awards)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
count = len(response.data)
|
||||
return {"success": True, "data": response.data, "meta": {"count": count}}
|
||||
|
||||
|
||||
@users_namespace.route("/<user_id>/solves")
|
||||
@users_namespace.param("user_id", "User ID")
|
||||
class UserPublicSolves(Resource):
|
||||
@check_account_visibility
|
||||
@check_score_visibility
|
||||
def get(self, user_id):
|
||||
user = Users.query.filter_by(id=user_id).first_or_404()
|
||||
|
||||
if (user.banned or user.hidden) and is_admin() is False:
|
||||
abort(404)
|
||||
|
||||
solves = user.get_solves(admin=is_admin())
|
||||
|
||||
view = "user" if not is_admin() else "admin"
|
||||
response = SubmissionSchema(view=view, many=True).dump(solves)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
count = len(response.data)
|
||||
return {"success": True, "data": response.data, "meta": {"count": count}}
|
||||
|
||||
|
||||
@users_namespace.route("/<user_id>/fails")
|
||||
@users_namespace.param("user_id", "User ID")
|
||||
class UserPublicFails(Resource):
|
||||
@check_account_visibility
|
||||
@check_score_visibility
|
||||
def get(self, user_id):
|
||||
user = Users.query.filter_by(id=user_id).first_or_404()
|
||||
|
||||
if (user.banned or user.hidden) and is_admin() is False:
|
||||
abort(404)
|
||||
fails = user.get_fails(admin=is_admin())
|
||||
|
||||
view = "user" if not is_admin() else "admin"
|
||||
|
||||
# We want to return the count purely for stats & graphs
|
||||
# but this data isn't really needed by the end user.
|
||||
# Only actually show fail data for admins.
|
||||
if is_admin():
|
||||
response = SubmissionSchema(view=view, many=True).dump(fails)
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
data = response.data
|
||||
else:
|
||||
data = []
|
||||
|
||||
count = len(fails)
|
||||
return {"success": True, "data": data, "meta": {"count": count}}
|
||||
|
||||
|
||||
@users_namespace.route("/<user_id>/awards")
|
||||
@users_namespace.param("user_id", "User ID or 'me'")
|
||||
class UserPublicAwards(Resource):
|
||||
@check_account_visibility
|
||||
@check_score_visibility
|
||||
def get(self, user_id):
|
||||
user = Users.query.filter_by(id=user_id).first_or_404()
|
||||
|
||||
if (user.banned or user.hidden) and is_admin() is False:
|
||||
abort(404)
|
||||
awards = user.get_awards(admin=is_admin())
|
||||
|
||||
view = "user" if not is_admin() else "admin"
|
||||
response = AwardSchema(view=view, many=True).dump(awards)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
count = len(response.data)
|
||||
return {"success": True, "data": response.data, "meta": {"count": count}}
|
||||
|
||||
|
||||
@users_namespace.route("/<int:user_id>/email")
|
||||
@users_namespace.param("user_id", "User ID")
|
||||
class UserEmails(Resource):
|
||||
@admins_only
|
||||
@users_namespace.doc(
|
||||
description="Endpoint to email a User object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
@ratelimit(method="POST", limit=10, interval=60)
|
||||
def post(self, user_id):
|
||||
req = request.get_json()
|
||||
text = req.get("text", "").strip()
|
||||
user = Users.query.filter_by(id=user_id).first_or_404()
|
||||
|
||||
if get_mail_provider() is None:
|
||||
return (
|
||||
{"success": False, "errors": {"": ["Email settings not configured"]}},
|
||||
400,
|
||||
)
|
||||
|
||||
if not text:
|
||||
return (
|
||||
{"success": False, "errors": {"text": ["Email text cannot be empty"]}},
|
||||
400,
|
||||
)
|
||||
|
||||
result, response = sendmail(addr=user.email, text=text)
|
||||
|
||||
if result is True:
|
||||
return {"success": True}
|
||||
else:
|
||||
return (
|
||||
{"success": False, "errors": {"": [response]}},
|
||||
400,
|
||||
)
|
||||
682
CTFd/auth.py
Normal file
682
CTFd/auth.py
Normal file
@@ -0,0 +1,682 @@
|
||||
import requests
|
||||
from flask import Blueprint, abort
|
||||
from flask import current_app as app
|
||||
from flask import redirect, render_template, request, session, url_for
|
||||
from flask_babel import lazy_gettext as _l
|
||||
|
||||
from CTFd.cache import cache, clear_team_session, clear_user_session
|
||||
from CTFd.exceptions.email import (
|
||||
UserConfirmTokenInvalidException,
|
||||
UserResetPasswordTokenInvalidException,
|
||||
)
|
||||
from CTFd.models import Brackets, Teams, UserFieldEntries, UserFields, Users, db
|
||||
from CTFd.utils import config, email, get_app_config, get_config
|
||||
from CTFd.utils import user as current_user
|
||||
from CTFd.utils import validators
|
||||
from CTFd.utils.config import can_send_mail, is_teams_mode
|
||||
from CTFd.utils.config.integrations import mlc_registration
|
||||
from CTFd.utils.config.visibility import registration_visible
|
||||
from CTFd.utils.crypto import verify_password
|
||||
from CTFd.utils.decorators import ratelimit
|
||||
from CTFd.utils.decorators.visibility import check_registration_visibility
|
||||
from CTFd.utils.helpers import error_for, get_errors, markup
|
||||
from CTFd.utils.logging import log
|
||||
from CTFd.utils.modes import TEAMS_MODE
|
||||
from CTFd.utils.security.auth import generate_preset_admin, login_user, logout_user
|
||||
from CTFd.utils.security.email import (
|
||||
remove_email_confirm_token,
|
||||
remove_reset_password_token,
|
||||
verify_email_confirm_token,
|
||||
verify_reset_password_token,
|
||||
)
|
||||
from CTFd.utils.validators import ValidationError
|
||||
|
||||
auth = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
@auth.route("/confirm", methods=["POST", "GET"])
|
||||
@auth.route("/confirm/<data>", methods=["POST", "GET"])
|
||||
@ratelimit(method="POST", limit=10, interval=60)
|
||||
def confirm(data=None):
|
||||
# If we can't send mails our behavior depends on verify_emails
|
||||
if not can_send_mail():
|
||||
if get_config("verify_emails") is False:
|
||||
return redirect(url_for("challenges.listing"))
|
||||
else:
|
||||
return render_template(
|
||||
"confirm.html",
|
||||
errors=[
|
||||
"Email verification is enabled but email sending isn't available. Please contact an admin to confirm your account"
|
||||
],
|
||||
)
|
||||
|
||||
# User is confirming email account
|
||||
if data and request.method == "GET":
|
||||
try:
|
||||
user_email = verify_email_confirm_token(data)
|
||||
except (UserConfirmTokenInvalidException):
|
||||
return render_template(
|
||||
"confirm.html",
|
||||
errors=["Your confirmation link is invalid, please generate a new one"],
|
||||
)
|
||||
|
||||
user = Users.query.filter_by(email=user_email).first_or_404()
|
||||
if user.verified:
|
||||
return redirect(url_for("views.settings"))
|
||||
|
||||
if (
|
||||
get_app_config("EMAIL_CONFIRMATION_REQUIRE_INTERACTION")
|
||||
and request.args.get("interaction") is None
|
||||
):
|
||||
button = """<button style="margin-top: 3rem; padding: 1rem;" onclick="
|
||||
let u = new window.URL(window.location.href);
|
||||
u.searchParams.set('interaction', '1');
|
||||
window.location.href = u;">Click Here to Confirm Email</button>"""
|
||||
return render_template("page.html", content=button)
|
||||
|
||||
user.verified = True
|
||||
log(
|
||||
"registrations",
|
||||
format="[{date}] {ip} - successful confirmation for {name}",
|
||||
name=user.name,
|
||||
)
|
||||
db.session.commit()
|
||||
remove_email_confirm_token(data)
|
||||
clear_user_session(user_id=user.id)
|
||||
# Only send this registration notification if we are preventing access to registered users only
|
||||
if get_config("verify_emails"):
|
||||
email.successful_registration_notification(user.email)
|
||||
db.session.close()
|
||||
if current_user.authed():
|
||||
return redirect(url_for("challenges.listing"))
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
# User is trying to start or restart the confirmation flow
|
||||
if current_user.authed() is False:
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
user = Users.query.filter_by(id=session["id"]).first_or_404()
|
||||
if user.verified:
|
||||
return redirect(url_for("views.settings"))
|
||||
|
||||
if data is None:
|
||||
if request.method == "POST":
|
||||
# User wants to resend their confirmation email
|
||||
email.verify_email_address(user.email)
|
||||
log(
|
||||
"registrations",
|
||||
format="[{date}] {ip} - {name} initiated a confirmation email resend",
|
||||
name=user.name,
|
||||
)
|
||||
return render_template(
|
||||
"confirm.html", infos=[f"Confirmation email sent to {user.email}!"]
|
||||
)
|
||||
elif request.method == "GET":
|
||||
# User has been directed to the confirm page
|
||||
return render_template("confirm.html")
|
||||
|
||||
|
||||
@auth.route("/reset_password", methods=["POST", "GET"])
|
||||
@auth.route("/reset_password/<data>", methods=["POST", "GET"])
|
||||
@ratelimit(method="POST", limit=10, interval=60)
|
||||
def reset_password(data=None):
|
||||
if config.can_send_mail() is False and data is None:
|
||||
return render_template(
|
||||
"reset_password.html",
|
||||
errors=[
|
||||
markup(
|
||||
"This CTF is not configured to send email.<br> Please contact an organizer to have your password reset."
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if data is not None:
|
||||
try:
|
||||
email_address = verify_reset_password_token(data)
|
||||
except (UserResetPasswordTokenInvalidException):
|
||||
return render_template(
|
||||
"reset_password.html",
|
||||
errors=["Your reset link is invalid, please generate a new one"],
|
||||
)
|
||||
|
||||
if request.method == "GET":
|
||||
return render_template("reset_password.html", mode="set")
|
||||
if request.method == "POST":
|
||||
password = request.form.get("password", "").strip()
|
||||
user = Users.query.filter_by(email=email_address).first_or_404()
|
||||
if user.oauth_id:
|
||||
return render_template(
|
||||
"reset_password.html",
|
||||
infos=[
|
||||
"Your account was registered via an authentication provider and does not have an associated password. Please login via your authentication provider."
|
||||
],
|
||||
)
|
||||
|
||||
pass_short = len(password) == 0
|
||||
if pass_short:
|
||||
return render_template(
|
||||
"reset_password.html", errors=[_l("Please pick a longer password")]
|
||||
)
|
||||
|
||||
password_min_length = int(get_config("password_min_length", default=0))
|
||||
pass_min = len(password) < password_min_length
|
||||
if pass_min:
|
||||
return render_template(
|
||||
"reset_password.html",
|
||||
errors=[
|
||||
_l(
|
||||
f"Password must be at least {password_min_length} characters"
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
user.password = password
|
||||
user.change_password = False
|
||||
db.session.commit()
|
||||
remove_reset_password_token(data)
|
||||
clear_user_session(user_id=user.id)
|
||||
log(
|
||||
"logins",
|
||||
format="[{date}] {ip} - successful password reset for {name}",
|
||||
name=user.name,
|
||||
)
|
||||
db.session.close()
|
||||
email.password_change_alert(user.email)
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
if request.method == "POST":
|
||||
email_address = request.form["email"].strip()
|
||||
user = Users.query.filter_by(email=email_address).first()
|
||||
|
||||
get_errors()
|
||||
|
||||
if not user:
|
||||
return render_template(
|
||||
"reset_password.html",
|
||||
infos=[
|
||||
_l(
|
||||
"If that account exists you will receive an email, please check your inbox"
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if user.oauth_id:
|
||||
return render_template(
|
||||
"reset_password.html",
|
||||
infos=[
|
||||
_l(
|
||||
"The email address associated with this account was registered via an authentication provider and does not have an associated password. Please login via your authentication provider."
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# Preferably this would be in a pipeline or multi but the benefit is minor
|
||||
limit = cache.inc(f"reset_password_attempt_user_{user.id}")
|
||||
cache.expire(f"reset_password_attempt_user_{user.id}", 180)
|
||||
if limit > 5:
|
||||
return render_template(
|
||||
"reset_password.html",
|
||||
errors=[
|
||||
_l("Too many password reset attempts. Please try again later.")
|
||||
],
|
||||
)
|
||||
email.forgot_password(email_address)
|
||||
|
||||
return render_template(
|
||||
"reset_password.html",
|
||||
infos=[
|
||||
_l(
|
||||
"If that account exists you will receive an email, please check your inbox"
|
||||
)
|
||||
],
|
||||
)
|
||||
return render_template("reset_password.html")
|
||||
|
||||
|
||||
@auth.route("/register", methods=["POST", "GET"])
|
||||
@check_registration_visibility
|
||||
@ratelimit(method="POST", limit=10, interval=5)
|
||||
def register():
|
||||
errors = get_errors()
|
||||
if current_user.authed():
|
||||
return redirect(url_for("challenges.listing"))
|
||||
|
||||
num_users_limit = int(get_config("num_users", default=0))
|
||||
num_users = Users.query.filter_by(banned=False, hidden=False).count()
|
||||
if num_users_limit and num_users >= num_users_limit:
|
||||
abort(
|
||||
403,
|
||||
description=f"Reached the maximum number of users ({num_users_limit}).",
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
name = request.form.get("name", "").strip()
|
||||
email_address = request.form.get("email", "").strip().lower()
|
||||
password = request.form.get("password", "").strip()
|
||||
|
||||
website = request.form.get("website")
|
||||
affiliation = request.form.get("affiliation")
|
||||
country = request.form.get("country")
|
||||
registration_code = str(request.form.get("registration_code", ""))
|
||||
bracket_id = request.form.get("bracket_id", None)
|
||||
|
||||
name_len = len(name) == 0
|
||||
names = (
|
||||
Users.query.add_columns(Users.name, Users.id).filter_by(name=name).first()
|
||||
)
|
||||
emails = (
|
||||
Users.query.add_columns(Users.email, Users.id)
|
||||
.filter_by(email=email_address)
|
||||
.first()
|
||||
)
|
||||
pass_short = len(password) == 0
|
||||
pass_long = len(password) > 128
|
||||
valid_email = validators.validate_email(email_address)
|
||||
team_name_email_check = validators.validate_email(name)
|
||||
|
||||
password_min_length = int(get_config("password_min_length", default=0))
|
||||
pass_min = len(password) < password_min_length
|
||||
|
||||
if get_config("registration_code"):
|
||||
if (
|
||||
registration_code.lower()
|
||||
!= str(get_config("registration_code", default="")).lower()
|
||||
):
|
||||
errors.append(_l("The registration code you entered was incorrect"))
|
||||
|
||||
# Process additional user fields
|
||||
fields = {}
|
||||
for field in UserFields.query.all():
|
||||
fields[field.id] = field
|
||||
|
||||
entries = {}
|
||||
for field_id, field in fields.items():
|
||||
value = request.form.get(f"fields[{field_id}]", "").strip()
|
||||
if field.required is True and (value is None or value == ""):
|
||||
errors.append(_l("Please provide all required fields"))
|
||||
break
|
||||
|
||||
if field.field_type == "boolean":
|
||||
entries[field_id] = bool(value)
|
||||
else:
|
||||
entries[field_id] = value
|
||||
|
||||
if country:
|
||||
try:
|
||||
validators.validate_country_code(country)
|
||||
valid_country = True
|
||||
except ValidationError:
|
||||
valid_country = False
|
||||
else:
|
||||
valid_country = True
|
||||
|
||||
if website:
|
||||
valid_website = validators.validate_url(website)
|
||||
else:
|
||||
valid_website = True
|
||||
|
||||
if affiliation:
|
||||
valid_affiliation = len(affiliation) < 128
|
||||
else:
|
||||
valid_affiliation = True
|
||||
|
||||
if bracket_id:
|
||||
valid_bracket = bool(
|
||||
Brackets.query.filter_by(id=bracket_id, type="users").first()
|
||||
)
|
||||
else:
|
||||
if Brackets.query.filter_by(type="users").count():
|
||||
valid_bracket = False
|
||||
else:
|
||||
valid_bracket = True
|
||||
|
||||
if not valid_email:
|
||||
errors.append(_l("Please enter a valid email address"))
|
||||
if email.check_email_is_whitelisted(email_address) is False:
|
||||
errors.append(_l("Your email address is not from an allowed domain"))
|
||||
if email.check_email_is_blacklisted(email_address) is True:
|
||||
errors.append(_l("Your email address is not from an allowed domain"))
|
||||
if names:
|
||||
errors.append(_l("That user name is already taken"))
|
||||
if team_name_email_check is True:
|
||||
errors.append(_l("Your user name cannot be an email address"))
|
||||
if emails:
|
||||
errors.append(_l("That email has already been used"))
|
||||
if pass_short:
|
||||
errors.append(_l("Pick a longer password"))
|
||||
if password_min_length and pass_min:
|
||||
errors.append(
|
||||
_l(f"Password must be at least {password_min_length} characters")
|
||||
)
|
||||
if pass_long:
|
||||
errors.append(_l("Pick a shorter password"))
|
||||
if name_len:
|
||||
errors.append(_l("Pick a longer user name"))
|
||||
if valid_website is False:
|
||||
errors.append(
|
||||
_l("Websites must be a proper URL starting with http or https")
|
||||
)
|
||||
if valid_country is False:
|
||||
errors.append(_l("Invalid country"))
|
||||
if valid_affiliation is False:
|
||||
errors.append(_l("Please provide a shorter affiliation"))
|
||||
if valid_bracket is False:
|
||||
errors.append(_l("Please provide a valid bracket"))
|
||||
|
||||
if len(errors) > 0:
|
||||
return render_template(
|
||||
"register.html",
|
||||
errors=errors,
|
||||
name=request.form["name"],
|
||||
email=request.form["email"],
|
||||
password=request.form["password"],
|
||||
)
|
||||
else:
|
||||
with app.app_context():
|
||||
user = Users(
|
||||
name=name,
|
||||
email=email_address,
|
||||
password=password,
|
||||
bracket_id=bracket_id,
|
||||
)
|
||||
|
||||
if website:
|
||||
user.website = website
|
||||
if affiliation:
|
||||
user.affiliation = affiliation
|
||||
if country:
|
||||
user.country = country
|
||||
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
db.session.flush()
|
||||
|
||||
for field_id, value in entries.items():
|
||||
entry = UserFieldEntries(
|
||||
field_id=field_id, value=value, user_id=user.id
|
||||
)
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
|
||||
login_user(user)
|
||||
|
||||
if request.args.get("next") and validators.is_safe_url(
|
||||
request.args.get("next")
|
||||
):
|
||||
return redirect(request.args.get("next"))
|
||||
|
||||
if config.can_send_mail() and get_config(
|
||||
"verify_emails"
|
||||
): # Confirming users is enabled and we can send email.
|
||||
log(
|
||||
"registrations",
|
||||
format="[{date}] {ip} - {name} registered (UNCONFIRMED) with {email}",
|
||||
name=user.name,
|
||||
email=user.email,
|
||||
)
|
||||
email.verify_email_address(user.email)
|
||||
db.session.close()
|
||||
return redirect(url_for("auth.confirm"))
|
||||
else: # Don't care about confirming users
|
||||
if (
|
||||
config.can_send_mail()
|
||||
): # We want to notify the user that they have registered.
|
||||
email.successful_registration_notification(user.email)
|
||||
|
||||
log(
|
||||
"registrations",
|
||||
format="[{date}] {ip} - {name} registered with {email}",
|
||||
name=user.name,
|
||||
email=user.email,
|
||||
)
|
||||
db.session.close()
|
||||
|
||||
if is_teams_mode():
|
||||
return redirect(url_for("teams.private"))
|
||||
|
||||
return redirect(url_for("challenges.listing"))
|
||||
else:
|
||||
return render_template("register.html", errors=errors)
|
||||
|
||||
|
||||
@auth.route("/login", methods=["POST", "GET"])
|
||||
@ratelimit(method="POST", limit=10, interval=5)
|
||||
def login():
|
||||
errors = get_errors()
|
||||
if request.method == "POST":
|
||||
name = request.form["name"]
|
||||
|
||||
# Check for preset admin credentials first
|
||||
preset_admin_name = get_app_config("PRESET_ADMIN_NAME")
|
||||
preset_admin_email = get_app_config("PRESET_ADMIN_EMAIL")
|
||||
preset_admin_password = get_app_config("PRESET_ADMIN_PASSWORD")
|
||||
|
||||
if preset_admin_name and preset_admin_email and preset_admin_password:
|
||||
password = request.form.get("password", "")
|
||||
# Check if credentials match preset admin
|
||||
if (
|
||||
name == preset_admin_name or name == preset_admin_email
|
||||
) and password == preset_admin_password:
|
||||
admin = generate_preset_admin()
|
||||
if admin:
|
||||
login_user(user=admin)
|
||||
return redirect(url_for("challenges.listing"))
|
||||
else:
|
||||
errors.append(
|
||||
"Preset admin user could not be created. Please contact an administrator"
|
||||
)
|
||||
return render_template("login.html", errors=errors)
|
||||
|
||||
# Check if the user submitted an email address or a team name
|
||||
if validators.validate_email(name) is True:
|
||||
user = Users.query.filter_by(email=name).first()
|
||||
else:
|
||||
user = Users.query.filter_by(name=name).first()
|
||||
|
||||
if user:
|
||||
if user.password is None:
|
||||
errors.append(
|
||||
"Your account was registered with a 3rd party authentication provider. "
|
||||
"Please try logging in with a configured authentication provider."
|
||||
)
|
||||
return render_template("login.html", errors=errors)
|
||||
|
||||
if user and verify_password(request.form["password"], user.password):
|
||||
session.regenerate()
|
||||
|
||||
login_user(user)
|
||||
log("logins", "[{date}] {ip} - {name} logged in", name=user.name)
|
||||
|
||||
db.session.close()
|
||||
if request.args.get("next") and validators.is_safe_url(
|
||||
request.args.get("next")
|
||||
):
|
||||
return redirect(request.args.get("next"))
|
||||
return redirect(url_for("challenges.listing"))
|
||||
|
||||
else:
|
||||
# This user exists but the password is wrong
|
||||
log(
|
||||
"logins",
|
||||
"[{date}] {ip} - submitted invalid password for {name}",
|
||||
name=user.name,
|
||||
)
|
||||
errors.append("Your username or password is incorrect")
|
||||
db.session.close()
|
||||
return render_template("login.html", errors=errors)
|
||||
else:
|
||||
# This user just doesn't exist
|
||||
log("logins", "[{date}] {ip} - submitted invalid account information")
|
||||
errors.append("Your username or password is incorrect")
|
||||
db.session.close()
|
||||
return render_template("login.html", errors=errors)
|
||||
else:
|
||||
db.session.close()
|
||||
return render_template("login.html", errors=errors)
|
||||
|
||||
|
||||
@auth.route("/oauth")
|
||||
def oauth_login():
|
||||
endpoint = (
|
||||
get_app_config("OAUTH_AUTHORIZATION_ENDPOINT")
|
||||
or get_config("oauth_authorization_endpoint")
|
||||
or "https://auth.majorleaguecyber.org/oauth/authorize"
|
||||
)
|
||||
|
||||
if get_config("user_mode") == "teams":
|
||||
scope = "profile team"
|
||||
else:
|
||||
scope = "profile"
|
||||
|
||||
client_id = get_app_config("OAUTH_CLIENT_ID") or get_config("oauth_client_id")
|
||||
|
||||
if client_id is None:
|
||||
error_for(
|
||||
endpoint="auth.login",
|
||||
message="OAuth Settings not configured. "
|
||||
"Ask your CTF administrator to configure MajorLeagueCyber integration.",
|
||||
)
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
redirect_url = "{endpoint}?response_type=code&client_id={client_id}&scope={scope}&state={state}".format(
|
||||
endpoint=endpoint, client_id=client_id, scope=scope, state=session["nonce"]
|
||||
)
|
||||
return redirect(redirect_url)
|
||||
|
||||
|
||||
@auth.route("/redirect", methods=["GET"])
|
||||
@ratelimit(method="GET", limit=10, interval=60)
|
||||
def oauth_redirect():
|
||||
oauth_code = request.args.get("code")
|
||||
state = request.args.get("state")
|
||||
if session["nonce"] != state:
|
||||
log("logins", "[{date}] {ip} - OAuth State validation mismatch")
|
||||
error_for(endpoint="auth.login", message="OAuth State validation mismatch.")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
if oauth_code:
|
||||
url = (
|
||||
get_app_config("OAUTH_TOKEN_ENDPOINT")
|
||||
or get_config("oauth_token_endpoint")
|
||||
or "https://auth.majorleaguecyber.org/oauth/token"
|
||||
)
|
||||
|
||||
client_id = get_app_config("OAUTH_CLIENT_ID") or get_config("oauth_client_id")
|
||||
client_secret = get_app_config("OAUTH_CLIENT_SECRET") or get_config(
|
||||
"oauth_client_secret"
|
||||
)
|
||||
headers = {"content-type": "application/x-www-form-urlencoded"}
|
||||
data = {
|
||||
"code": oauth_code,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"grant_type": "authorization_code",
|
||||
}
|
||||
token_request = requests.post(url, data=data, headers=headers, timeout=5)
|
||||
|
||||
if token_request.status_code == requests.codes.ok:
|
||||
token = token_request.json()["access_token"]
|
||||
user_url = (
|
||||
get_app_config("OAUTH_API_ENDPOINT")
|
||||
or get_config("oauth_api_endpoint")
|
||||
or "https://api.majorleaguecyber.org/user"
|
||||
)
|
||||
|
||||
headers = {
|
||||
"Authorization": "Bearer " + str(token),
|
||||
"Content-type": "application/json",
|
||||
}
|
||||
api_data = requests.get(url=user_url, headers=headers, timeout=5).json()
|
||||
|
||||
user_id = api_data["id"]
|
||||
user_name = api_data["name"]
|
||||
user_email = api_data["email"]
|
||||
|
||||
user = Users.query.filter_by(email=user_email).first()
|
||||
if user is None:
|
||||
# Respect the user count limit
|
||||
num_users_limit = int(get_config("num_users", default=0))
|
||||
num_users = Users.query.filter_by(banned=False, hidden=False).count()
|
||||
if num_users_limit and num_users >= num_users_limit:
|
||||
abort(
|
||||
403,
|
||||
description=f"Reached the maximum number of users ({num_users_limit}).",
|
||||
)
|
||||
|
||||
# Check if we are allowing registration before creating users
|
||||
if registration_visible() or mlc_registration():
|
||||
user = Users(
|
||||
name=user_name,
|
||||
email=user_email,
|
||||
oauth_id=user_id,
|
||||
verified=True,
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
else:
|
||||
log("logins", "[{date}] {ip} - Public registration via MLC blocked")
|
||||
error_for(
|
||||
endpoint="auth.login",
|
||||
message="Public registration is disabled. Please try again later.",
|
||||
)
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
if get_config("user_mode") == TEAMS_MODE and user.team_id is None:
|
||||
team_id = api_data["team"]["id"]
|
||||
team_name = api_data["team"]["name"]
|
||||
|
||||
team = Teams.query.filter_by(oauth_id=team_id).first()
|
||||
if team is None:
|
||||
num_teams_limit = int(get_config("num_teams", default=0))
|
||||
num_teams = Teams.query.filter_by(
|
||||
banned=False, hidden=False
|
||||
).count()
|
||||
if num_teams_limit and num_teams >= num_teams_limit:
|
||||
abort(
|
||||
403,
|
||||
description=f"Reached the maximum number of teams ({num_teams_limit}). Please join an existing team.",
|
||||
)
|
||||
|
||||
team = Teams(name=team_name, oauth_id=team_id, captain_id=user.id)
|
||||
db.session.add(team)
|
||||
db.session.commit()
|
||||
clear_team_session(team_id=team.id)
|
||||
|
||||
team_size_limit = get_config("team_size", default=0)
|
||||
if team_size_limit and len(team.members) >= team_size_limit:
|
||||
plural = "" if team_size_limit == 1 else "s"
|
||||
size_error = "Teams are limited to {limit} member{plural}.".format(
|
||||
limit=team_size_limit, plural=plural
|
||||
)
|
||||
error_for(endpoint="auth.login", message=size_error)
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
team.members.append(user)
|
||||
db.session.commit()
|
||||
|
||||
if user.oauth_id is None:
|
||||
user.oauth_id = user_id
|
||||
user.verified = True
|
||||
db.session.commit()
|
||||
clear_user_session(user_id=user.id)
|
||||
|
||||
login_user(user)
|
||||
|
||||
return redirect(url_for("challenges.listing"))
|
||||
else:
|
||||
log("logins", "[{date}] {ip} - OAuth token retrieval failure")
|
||||
error_for(endpoint="auth.login", message="OAuth token retrieval failure.")
|
||||
return redirect(url_for("auth.login"))
|
||||
else:
|
||||
log("logins", "[{date}] {ip} - Received redirect without OAuth code")
|
||||
error_for(
|
||||
endpoint="auth.login", message="Received redirect without OAuth code."
|
||||
)
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@auth.route("/logout")
|
||||
def logout():
|
||||
if current_user.authed():
|
||||
logout_user()
|
||||
return redirect(url_for("views.static_html"))
|
||||
253
CTFd/cache/__init__.py
vendored
Normal file
253
CTFd/cache/__init__.py
vendored
Normal file
@@ -0,0 +1,253 @@
|
||||
from functools import lru_cache, wraps
|
||||
from hashlib import md5
|
||||
from time import monotonic_ns
|
||||
|
||||
from flask import current_app, request
|
||||
from flask_caching import Cache, make_template_fragment_key
|
||||
|
||||
|
||||
class CTFdCache(Cache):
|
||||
"""
|
||||
This subclass exists to give flask-caching some additional features
|
||||
Ideally likely we should have our own isolated redis connection but that might introduce more issues
|
||||
"""
|
||||
|
||||
def inc(self, *args, **kwargs):
|
||||
"""
|
||||
Support redis INCR in flask-caching
|
||||
Note that redis INCR does not expire by default
|
||||
https://github.com/pallets-eco/flask-caching/issues/418
|
||||
"""
|
||||
inc = getattr(self.cache, "inc", None)
|
||||
if inc is not None and callable(inc):
|
||||
return inc(*args, **kwargs)
|
||||
raise NotImplementedError
|
||||
|
||||
def expire(self, key, timeout):
|
||||
"""
|
||||
Support redis EXPIRE in flask-caching
|
||||
"""
|
||||
if current_app.config["CACHE_TYPE"] == "redis":
|
||||
return self.cache._write_client.expire(
|
||||
f"{self.cache.key_prefix}{key}", timeout
|
||||
)
|
||||
else:
|
||||
# Generic alternative that leverages flask-caching built-ins to do expiration
|
||||
if timeout <= 0:
|
||||
self.cache.delete(key)
|
||||
value = self.get(key)
|
||||
if value:
|
||||
self.set(key=key, value=value, timeout=timeout)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
cache = CTFdCache()
|
||||
|
||||
|
||||
def timed_lru_cache(timeout: int = 300, maxsize: int = 64, typed: bool = False):
|
||||
"""
|
||||
lru_cache implementation that includes a time based expiry
|
||||
|
||||
Parameters:
|
||||
seconds (int): Timeout in seconds to clear the WHOLE cache, default = 5 minutes
|
||||
maxsize (int): Maximum Size of the Cache
|
||||
typed (bool): Same value of different type will be a different entry
|
||||
|
||||
Implmentation from https://gist.github.com/Morreski/c1d08a3afa4040815eafd3891e16b945?permalink_comment_id=3437689#gistcomment-3437689
|
||||
"""
|
||||
|
||||
def wrapper_cache(func):
|
||||
func = lru_cache(maxsize=maxsize, typed=typed)(func)
|
||||
func.delta = timeout * 10**9
|
||||
func.expiration = monotonic_ns() + func.delta
|
||||
|
||||
@wraps(func)
|
||||
def wrapped_func(*args, **kwargs):
|
||||
if monotonic_ns() >= func.expiration:
|
||||
func.cache_clear()
|
||||
func.expiration = monotonic_ns() + func.delta
|
||||
return func(*args, **kwargs)
|
||||
|
||||
wrapped_func.cache_info = func.cache_info
|
||||
wrapped_func.cache_clear = func.cache_clear
|
||||
return wrapped_func
|
||||
|
||||
return wrapper_cache
|
||||
|
||||
|
||||
def make_cache_key(path=None, key_prefix="view/%s"):
|
||||
"""
|
||||
This function mostly emulates Flask-Caching's `make_cache_key` function so we can delete cached api responses.
|
||||
Over time this function may be replaced with a cleaner custom cache implementation.
|
||||
:param path:
|
||||
:param key_prefix:
|
||||
:return:
|
||||
"""
|
||||
if path is None:
|
||||
path = request.endpoint
|
||||
cache_key = key_prefix % path
|
||||
return cache_key
|
||||
|
||||
|
||||
def make_cache_key_with_query_string(allowed_params=None, query_string_hash=None):
|
||||
if allowed_params is None:
|
||||
allowed_params = []
|
||||
|
||||
def _make_cache_key_with_query_string(path=None, key_prefix="view/%s/%s"):
|
||||
if path is None:
|
||||
path = request.endpoint
|
||||
|
||||
if query_string_hash:
|
||||
args_hash = query_string_hash
|
||||
else:
|
||||
args_hash = calculate_param_hash(
|
||||
params=tuple(request.args.items(multi=True)),
|
||||
allowed_params=allowed_params,
|
||||
)
|
||||
cache_key = key_prefix % (path, args_hash)
|
||||
return cache_key
|
||||
|
||||
return _make_cache_key_with_query_string
|
||||
|
||||
|
||||
def calculate_param_hash(params, allowed_params=None):
|
||||
# Copied from Flask-Caching but modified to allow only accepted parameters
|
||||
if allowed_params:
|
||||
args_as_sorted_tuple = tuple(
|
||||
sorted(pair for pair in params if pair[0] in allowed_params)
|
||||
)
|
||||
else:
|
||||
args_as_sorted_tuple = tuple(sorted(pair for pair in params))
|
||||
args_hash = md5(str(args_as_sorted_tuple).encode()).hexdigest() # nosec B303 B324
|
||||
return args_hash
|
||||
|
||||
|
||||
def clear_config():
|
||||
from CTFd.utils import _get_config, get_app_config
|
||||
|
||||
cache.delete_memoized(_get_config)
|
||||
cache.delete_memoized(get_app_config)
|
||||
|
||||
|
||||
def clear_standings():
|
||||
from CTFd.api import api
|
||||
from CTFd.api.v1.scoreboard import ScoreboardDetail, ScoreboardList
|
||||
from CTFd.constants.static import CacheKeys
|
||||
from CTFd.models import Teams, Users # noqa: I001
|
||||
from CTFd.utils.scoreboard import get_scoreboard_detail
|
||||
from CTFd.utils.scores import get_standings, get_team_standings, get_user_standings
|
||||
from CTFd.utils.user import (
|
||||
get_team_place,
|
||||
get_team_score,
|
||||
get_user_place,
|
||||
get_user_score,
|
||||
)
|
||||
|
||||
# Clear out the bulk standings functions
|
||||
cache.delete_memoized(get_standings)
|
||||
cache.delete_memoized(get_team_standings)
|
||||
cache.delete_memoized(get_user_standings)
|
||||
cache.delete_memoized(get_scoreboard_detail)
|
||||
|
||||
# Clear out the individual helpers for accessing score via the model
|
||||
cache.delete_memoized(Users.get_score)
|
||||
cache.delete_memoized(Users.get_place)
|
||||
cache.delete_memoized(Teams.get_score)
|
||||
cache.delete_memoized(Teams.get_place)
|
||||
|
||||
# Clear the Jinja Attrs constants
|
||||
cache.delete_memoized(get_user_score)
|
||||
cache.delete_memoized(get_user_place)
|
||||
cache.delete_memoized(get_team_score)
|
||||
cache.delete_memoized(get_team_place)
|
||||
|
||||
# Clear out HTTP request responses
|
||||
cache.delete(make_cache_key(path=api.name + "." + ScoreboardList.endpoint))
|
||||
cache.delete(make_cache_key(path=api.name + "." + ScoreboardDetail.endpoint))
|
||||
cache.delete_memoized(ScoreboardList.get)
|
||||
cache.delete_memoized(ScoreboardDetail.get)
|
||||
|
||||
# Clear out scoreboard templates
|
||||
cache.delete(make_template_fragment_key(CacheKeys.PUBLIC_SCOREBOARD_TABLE))
|
||||
|
||||
|
||||
def clear_challenges():
|
||||
from CTFd.utils.challenges import get_all_challenges # noqa: I001
|
||||
from CTFd.utils.challenges import (
|
||||
get_rating_average_for_challenge_id,
|
||||
get_solve_counts_for_challenges,
|
||||
get_solve_ids_for_user_id,
|
||||
get_solves_for_challenge_id,
|
||||
get_submissions_for_user_id_for_challenge_id,
|
||||
)
|
||||
|
||||
cache.delete_memoized(get_all_challenges)
|
||||
cache.delete_memoized(get_solves_for_challenge_id)
|
||||
cache.delete_memoized(get_submissions_for_user_id_for_challenge_id)
|
||||
cache.delete_memoized(get_solve_ids_for_user_id)
|
||||
cache.delete_memoized(get_solve_counts_for_challenges)
|
||||
cache.delete_memoized(get_rating_average_for_challenge_id)
|
||||
|
||||
|
||||
def clear_ratings():
|
||||
from CTFd.utils.challenges import get_rating_average_for_challenge_id
|
||||
|
||||
cache.delete_memoized(get_rating_average_for_challenge_id)
|
||||
|
||||
|
||||
def clear_pages():
|
||||
from CTFd.utils.config.pages import get_page, get_pages
|
||||
|
||||
cache.delete_memoized(get_pages)
|
||||
cache.delete_memoized(get_page)
|
||||
|
||||
|
||||
def clear_user_recent_ips(user_id):
|
||||
from CTFd.utils.user import get_user_recent_ips
|
||||
|
||||
cache.delete_memoized(get_user_recent_ips, user_id=user_id)
|
||||
|
||||
|
||||
def clear_user_session(user_id):
|
||||
from CTFd.utils.user import ( # noqa: I001
|
||||
get_user_attrs,
|
||||
get_user_place,
|
||||
get_user_recent_ips,
|
||||
get_user_score,
|
||||
)
|
||||
|
||||
cache.delete_memoized(get_user_attrs, user_id=user_id)
|
||||
cache.delete_memoized(get_user_place, user_id=user_id)
|
||||
cache.delete_memoized(get_user_score, user_id=user_id)
|
||||
cache.delete_memoized(get_user_recent_ips, user_id=user_id)
|
||||
|
||||
|
||||
def clear_all_user_sessions():
|
||||
from CTFd.utils.user import ( # noqa: I001
|
||||
get_user_attrs,
|
||||
get_user_place,
|
||||
get_user_recent_ips,
|
||||
get_user_score,
|
||||
)
|
||||
|
||||
cache.delete_memoized(get_user_attrs)
|
||||
cache.delete_memoized(get_user_place)
|
||||
cache.delete_memoized(get_user_score)
|
||||
cache.delete_memoized(get_user_recent_ips)
|
||||
|
||||
|
||||
def clear_team_session(team_id):
|
||||
from CTFd.utils.user import get_team_attrs, get_team_place, get_team_score
|
||||
|
||||
cache.delete_memoized(get_team_attrs, team_id=team_id)
|
||||
cache.delete_memoized(get_team_place, team_id=team_id)
|
||||
cache.delete_memoized(get_team_score, team_id=team_id)
|
||||
|
||||
|
||||
def clear_all_team_sessions():
|
||||
from CTFd.utils.user import get_team_attrs, get_team_place, get_team_score
|
||||
|
||||
cache.delete_memoized(get_team_attrs)
|
||||
cache.delete_memoized(get_team_place)
|
||||
cache.delete_memoized(get_team_score)
|
||||
49
CTFd/challenges.py
Normal file
49
CTFd/challenges.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from flask import Blueprint, redirect, render_template, request, url_for
|
||||
from flask_babel import lazy_gettext as _l
|
||||
|
||||
from CTFd.constants.config import ChallengeVisibilityTypes, Configs
|
||||
from CTFd.utils.config import is_teams_mode
|
||||
from CTFd.utils.dates import ctf_ended, ctf_paused, ctf_started
|
||||
from CTFd.utils.decorators import (
|
||||
during_ctf_time_only,
|
||||
require_complete_profile,
|
||||
require_verified_emails,
|
||||
)
|
||||
from CTFd.utils.decorators.visibility import check_challenge_visibility
|
||||
from CTFd.utils.helpers import get_errors, get_infos
|
||||
from CTFd.utils.user import authed, get_current_team
|
||||
|
||||
challenges = Blueprint("challenges", __name__)
|
||||
|
||||
|
||||
@challenges.route("/challenges", methods=["GET"])
|
||||
@require_complete_profile
|
||||
@during_ctf_time_only
|
||||
@require_verified_emails
|
||||
@check_challenge_visibility
|
||||
def listing():
|
||||
if (
|
||||
Configs.challenge_visibility == ChallengeVisibilityTypes.PUBLIC
|
||||
and authed() is False
|
||||
):
|
||||
pass
|
||||
else:
|
||||
if is_teams_mode() and get_current_team() is None:
|
||||
return redirect(url_for("teams.private", next=request.full_path))
|
||||
|
||||
infos = get_infos()
|
||||
errors = get_errors()
|
||||
|
||||
if Configs.challenge_visibility == ChallengeVisibilityTypes.ADMINS:
|
||||
infos.append(_l("Challenge Visibility is set to Admins Only"))
|
||||
|
||||
if ctf_started() is False:
|
||||
errors.append(_l("%(ctf_name)s has not started yet", ctf_name=Configs.ctf_name))
|
||||
|
||||
if ctf_paused() is True:
|
||||
infos.append(_l("%(ctf_name)s is paused", ctf_name=Configs.ctf_name))
|
||||
|
||||
if ctf_ended() is True:
|
||||
infos.append(_l("%(ctf_name)s has ended", ctf_name=Configs.ctf_name))
|
||||
|
||||
return render_template("challenges.html", infos=infos, errors=errors)
|
||||
92
CTFd/cli/__init__.py
Normal file
92
CTFd/cli/__init__.py
Normal file
@@ -0,0 +1,92 @@
|
||||
import datetime
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from flask import Blueprint, current_app
|
||||
|
||||
from CTFd.utils import get_config as get_config_util
|
||||
from CTFd.utils import set_config as set_config_util
|
||||
from CTFd.utils.config import ctf_name
|
||||
from CTFd.utils.exports import export_ctf as export_ctf_util
|
||||
from CTFd.utils.exports import import_ctf as import_ctf_util
|
||||
from CTFd.utils.exports import set_import_end_time, set_import_error
|
||||
|
||||
_cli = Blueprint("cli", __name__)
|
||||
|
||||
|
||||
def jsenums():
|
||||
import json
|
||||
import os
|
||||
|
||||
from CTFd.constants import JS_ENUMS
|
||||
|
||||
path = os.path.join(current_app.root_path, "themes/core/assets/js/constants.js")
|
||||
|
||||
with open(path, "w+") as f:
|
||||
for k, v in JS_ENUMS.items():
|
||||
f.write("const {} = Object.freeze({});".format(k, json.dumps(v)))
|
||||
|
||||
|
||||
BUILD_COMMANDS = {"jsenums": jsenums}
|
||||
|
||||
|
||||
@_cli.cli.command("get_config")
|
||||
@click.argument("key")
|
||||
def get_config(key):
|
||||
print(get_config_util(key))
|
||||
|
||||
|
||||
@_cli.cli.command("set_config")
|
||||
@click.argument("key")
|
||||
@click.argument("value")
|
||||
def set_config(key, value):
|
||||
print(set_config_util(key, value).value)
|
||||
|
||||
|
||||
@_cli.cli.command("build")
|
||||
@click.argument("cmd")
|
||||
def build(cmd):
|
||||
cmd = BUILD_COMMANDS.get(cmd)
|
||||
cmd()
|
||||
|
||||
|
||||
@_cli.cli.command("export_ctf")
|
||||
@click.argument("path", default="")
|
||||
def export_ctf(path):
|
||||
backup = export_ctf_util()
|
||||
|
||||
if path:
|
||||
with open(path, "wb") as target:
|
||||
shutil.copyfileobj(backup, target)
|
||||
else:
|
||||
name = ctf_name()
|
||||
day = datetime.datetime.now().strftime("%Y-%m-%d_%T")
|
||||
full_name = f"{name}.{day}.zip"
|
||||
|
||||
with open(full_name, "wb") as target:
|
||||
shutil.copyfileobj(backup, target)
|
||||
|
||||
print(f"Exported {full_name}")
|
||||
|
||||
|
||||
@_cli.cli.command("import_ctf")
|
||||
@click.argument("path", type=click.Path(exists=True))
|
||||
@click.option(
|
||||
"--delete_import_on_finish",
|
||||
default=False,
|
||||
is_flag=True,
|
||||
help="Delete import file when import is finished",
|
||||
)
|
||||
def import_ctf(path, delete_import_on_finish=False):
|
||||
try:
|
||||
import_ctf_util(path)
|
||||
except Exception as e:
|
||||
from CTFd.utils.dates import unix_time
|
||||
|
||||
set_import_error("Import Failure: " + str(e))
|
||||
set_import_end_time(value=unix_time(datetime.datetime.utcnow()))
|
||||
|
||||
if delete_import_on_finish:
|
||||
print(f"Deleting {path}")
|
||||
Path(path).unlink()
|
||||
361
CTFd/config.ini
Normal file
361
CTFd/config.ini
Normal file
@@ -0,0 +1,361 @@
|
||||
# CTFd Configuration File
|
||||
#
|
||||
# Use this file to configure aspects of how CTFd behaves. Additional attributes can be specified for
|
||||
# plugins and other additional behavior.
|
||||
#
|
||||
# If a configuration item is specified but left empty, CTFd will do the following:
|
||||
#
|
||||
# 1. Look for an environment variable under the same name and use that value if found
|
||||
# 2. Use a default value specified in it's own internal configuration
|
||||
# 3. Use a null value (i.e. None) or empty string for the configuration value
|
||||
|
||||
|
||||
[server]
|
||||
# SECRET_KEY:
|
||||
# The secret value used to creation sessions and sign strings. This should be set to a random string. In the
|
||||
# interest of ease, CTFd will automatically create a secret key file for you. If you wish to add this secret key
|
||||
# to your instance you should hard code this value to a random static value.
|
||||
#
|
||||
# You can also remove .ctfd_secret_key from the .gitignore file and commit this file into whatever repository
|
||||
# you are using.
|
||||
#
|
||||
# http://flask.pocoo.org/docs/latest/quickstart/#sessions
|
||||
SECRET_KEY =
|
||||
|
||||
# DATABASE_URL
|
||||
# The URI that specifies the username, password, hostname, port, and database of the server
|
||||
# used to hold the CTFd database.
|
||||
#
|
||||
# If neither this setting nor `DATABASE_HOST` is specified, CTFd will automatically create a SQLite database for you to use
|
||||
# e.g. mysql+pymysql://root:<YOUR_PASSWORD_HERE>@localhost/ctfd
|
||||
DATABASE_URL =
|
||||
|
||||
# DATABASE_HOST
|
||||
# The hostname of the database server used to hold the CTFd database.
|
||||
# If `DATABASE_URL` is set, this setting will have no effect.
|
||||
#
|
||||
# This option, along with the other `DATABASE_*` options, are an alternative to specifying all connection details in the single `DATABASE_URL`.
|
||||
# If neither this setting nor `DATABASE_URL` is specified, CTFd will automatically create a SQLite database for you to use.
|
||||
DATABASE_HOST =
|
||||
|
||||
# DATABASE_PROTOCOL
|
||||
# The protocol used to access the database server, if `DATABASE_HOST` is set. Defaults to `mysql+pymysql`.
|
||||
DATABASE_PROTOCOL =
|
||||
|
||||
# DATABASE_USER
|
||||
# The username used to access the database server, if `DATABASE_HOST` is set. Defaults to `ctfd`.
|
||||
DATABASE_USER =
|
||||
|
||||
# DATABASE_PASSWORD
|
||||
# The password used to access the database server, if `DATABASE_HOST` is set.
|
||||
DATABASE_PASSWORD =
|
||||
|
||||
# DATABASE_PORT
|
||||
# The port used to access the database server, if `DATABASE_HOST` is set.
|
||||
DATABASE_PORT =
|
||||
|
||||
# DATABASE_NAME
|
||||
# The name of the database to access on the database server, if `DATABASE_HOST` is set. Defaults to `ctfd`.
|
||||
DATABASE_NAME =
|
||||
|
||||
# REDIS_URL
|
||||
# The URL to connect to a Redis server. If neither this setting nor `REDIS_HOST` is specified,
|
||||
# CTFd will use the .data folder as a filesystem cache.
|
||||
#
|
||||
# e.g. redis://user:password@localhost:6379
|
||||
# http://pythonhosted.org/Flask-Caching/#configuring-flask-caching
|
||||
REDIS_URL =
|
||||
|
||||
# REDIS_HOST
|
||||
# The hostname of the Redis server to connect to.
|
||||
# If `REDIS_URL` is set, this setting will have no effect.
|
||||
#
|
||||
# This option, along with the other `REDIS_*` options, are an alternative to specifying all connection details in the single `REDIS_URL`.
|
||||
# If neither this setting nor `REDIS_URL` is specified, CTFd will use the .data folder as a filesystem cache.
|
||||
REDIS_HOST =
|
||||
|
||||
# REDIS_PROTOCOL
|
||||
# The protocol used to access the Redis server, if `REDIS_HOST` is set. Defaults to `redis`.
|
||||
#
|
||||
# Note that the `unix` protocol is not supported here; use `REDIS_URL` instead.
|
||||
REDIS_PROTOCOL =
|
||||
|
||||
# REDIS_USER
|
||||
# The username used to access the Redis server, if `REDIS_HOST` is set.
|
||||
REDIS_USER =
|
||||
|
||||
# REDIS_PASSWORD
|
||||
# The password used to access the Redis server, if `REDIS_HOST` is set.
|
||||
REDIS_PASSWORD =
|
||||
|
||||
# REDIS_PORT
|
||||
# The port used to access the Redis server, if `REDIS_HOST` is set.
|
||||
REDIS_PORT =
|
||||
|
||||
# REDIS_DB
|
||||
# The index of the Redis database to access, if `REDIS_HOST` is set.
|
||||
REDIS_DB =
|
||||
|
||||
[security]
|
||||
# TRUSTED_HOSTS
|
||||
# Comma seperated string containing valid host names for CTFd to respond to. If not specified,
|
||||
# CTFd will respond to requests for any host unless otherwise restricted in an upstream proxy.
|
||||
# Each value is either an exact match, or can start with a dot . to match any subdomain.
|
||||
#
|
||||
# It is recommended that most users set this to the server name that they expect to be using
|
||||
#
|
||||
# Example: example.com,ctfd.io,.ctfd.io
|
||||
# See https://flask.palletsprojects.com/en/stable/config/#TRUSTED_HOSTS
|
||||
TRUSTED_HOSTS =
|
||||
|
||||
# SESSION_COOKIE_HTTPONLY
|
||||
# Controls if cookies should be set with the HttpOnly flag. Defaults to True.
|
||||
SESSION_COOKIE_HTTPONLY = true
|
||||
|
||||
# SESSION_COOKIE_SAMESITE
|
||||
# Controls the SameSite attribute on session cookies. Can be Lax or Strict.
|
||||
# Should be left as Lax unless the implications are well understood
|
||||
SESSION_COOKIE_SAMESITE = Lax
|
||||
|
||||
# PERMANENT_SESSION_LIFETIME
|
||||
# The lifetime of a session. The default is 604800 seconds (7 days).
|
||||
PERMANENT_SESSION_LIFETIME = 604800
|
||||
|
||||
# CROSS_ORIGIN_OPENER_POLICY
|
||||
# Setting for the Cross-Origin-Opener-Policy response header. Defaults to same-origin-allow-popups
|
||||
CROSS_ORIGIN_OPENER_POLICY =
|
||||
|
||||
[email]
|
||||
# MAILFROM_ADDR
|
||||
# The email address that emails are sent from if not overridden in the configuration panel.
|
||||
MAILFROM_ADDR =
|
||||
|
||||
# MAIL_SERVER
|
||||
# The mail server that emails are sent from if not overriden in the configuration panel.
|
||||
MAIL_SERVER =
|
||||
|
||||
# MAIL_PORT
|
||||
# The mail port that emails are sent from if not overriden in the configuration panel.
|
||||
MAIL_PORT =
|
||||
|
||||
# MAIL_USEAUTH
|
||||
# Whether or not to use username and password to authenticate to the SMTP server
|
||||
MAIL_USEAUTH =
|
||||
|
||||
# MAIL_USERNAME
|
||||
# The username used to authenticate to the SMTP server if MAIL_USEAUTH is defined
|
||||
MAIL_USERNAME =
|
||||
|
||||
# MAIL_PASSWORD
|
||||
# The password used to authenticate to the SMTP server if MAIL_USEAUTH is defined
|
||||
MAIL_PASSWORD =
|
||||
|
||||
# MAIL_TLS
|
||||
# Whether to connect to the SMTP server over TLS
|
||||
MAIL_TLS =
|
||||
|
||||
# MAIL_SSL
|
||||
# Whether to connect to the SMTP server over SSL
|
||||
MAIL_SSL =
|
||||
|
||||
# MAILSENDER_ADDR
|
||||
# The email address that is responsible for the transmission of emails.
|
||||
# This is very often the MAILFROM_ADDR value but can be specified if your email
|
||||
# is delivered by a different domain than what's specified in your MAILFROM_ADDR.
|
||||
# If this isn't specified, the MAILFROM_ADDR value is used.
|
||||
# It is fairly rare to need to set this value.
|
||||
MAILSENDER_ADDR =
|
||||
|
||||
# MAILGUN_API_KEY
|
||||
# Mailgun API key to send email over Mailgun. As of CTFd v3, Mailgun integration is deprecated.
|
||||
# Installations using the Mailgun API should migrate over to SMTP settings.
|
||||
MAILGUN_API_KEY =
|
||||
|
||||
# MAILGUN_BASE_URL
|
||||
# Mailgun base url to send email over Mailgun. As of CTFd v3, Mailgun integration is deprecated.
|
||||
# Installations using the Mailgun API should migrate over to SMTP settings.
|
||||
MAILGUN_BASE_URL =
|
||||
|
||||
# MAIL_PROVIDER
|
||||
# Specifies the email provider that CTFd will use to send email.
|
||||
# By default CTFd will automatically detect the correct email provider based on the other settings
|
||||
# specified here or in the configuration panel. This setting can be used to force a specific provider.
|
||||
MAIL_PROVIDER =
|
||||
|
||||
[uploads]
|
||||
# UPLOAD_PROVIDER
|
||||
# Specifies the service that CTFd should use to store files.
|
||||
# Can be set to filesystem or s3
|
||||
UPLOAD_PROVIDER =
|
||||
|
||||
# UPLOAD_FOLDER
|
||||
# The location where files are uploaded under the filesystem uploader.
|
||||
# The default destination is the CTFd/uploads folder.
|
||||
UPLOAD_FOLDER =
|
||||
|
||||
# AWS_ACCESS_KEY_ID
|
||||
# AWS access token used to authenticate to the S3 bucket. Only used under the s3 uploader.
|
||||
AWS_ACCESS_KEY_ID =
|
||||
|
||||
# AWS_SECRET_ACCESS_KEY
|
||||
# AWS secret token used to authenticate to the S3 bucket. Only used under the s3 uploader.
|
||||
AWS_SECRET_ACCESS_KEY =
|
||||
|
||||
# AWS_S3_BUCKET
|
||||
# The unique identifier for your S3 bucket. Only used under the s3 uploader.
|
||||
AWS_S3_BUCKET =
|
||||
|
||||
# AWS_S3_ENDPOINT_URL
|
||||
# A URL pointing to a custom S3 implementation. Only used under the s3 uploader.
|
||||
AWS_S3_ENDPOINT_URL =
|
||||
|
||||
# AWS_S3_REGION
|
||||
# The aws region that hosts your bucket. Only used in the s3 uploader.
|
||||
AWS_S3_REGION =
|
||||
|
||||
# AWS_S3_ADDRESSING_STYLE
|
||||
# The S3 addressing style to use for URLs. Only used under the s3 uploader.
|
||||
# Defaults to auto; can be set to virtual or path.
|
||||
# See https://boto3.amazonaws.com/v1/documentation/api/latest/guide/configuration.html
|
||||
AWS_S3_ADDRESSING_STYLE =
|
||||
|
||||
# AWS_S3_CUSTOM_DOMAIN
|
||||
# A hostname that replaces the default hostname in the generated S3 download URLs. Required by some S3 providers or CDNs.
|
||||
# Only used under the s3 uploader.
|
||||
AWS_S3_CUSTOM_DOMAIN =
|
||||
|
||||
# AWS_S3_CUSTOM_PREFIX
|
||||
# Add a prefix to the file path to be placed in S3.
|
||||
# Only used under the s3 uploader.
|
||||
AWS_S3_CUSTOM_PREFIX =
|
||||
|
||||
[logs]
|
||||
# LOG_FOLDER
|
||||
# The location where logs are written. These are the logs for CTFd key submissions, registrations, and logins. The default location is the CTFd/logs folder.
|
||||
LOG_FOLDER =
|
||||
|
||||
[optional]
|
||||
# REVERSE_PROXY
|
||||
# Specifies whether CTFd is behind a reverse proxy or not. Set to true if using a reverse proxy like nginx.
|
||||
# You can also specify a comma seperated set of numbers specifying the reverse proxy configuration settings.
|
||||
# See https://werkzeug.palletsprojects.com/en/0.15.x/middleware/proxy_fix/#werkzeug.middleware.proxy_fix.ProxyFix.
|
||||
# For example to configure `x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1` specify `1,1,1,1,1`.
|
||||
# If you specify `true` CTFd will default to the above behavior with all proxy settings set to 1.
|
||||
REVERSE_PROXY =
|
||||
|
||||
# THEME_FALLBACK
|
||||
# Specifies whether CTFd will fallback to the default "core" theme for missing pages/content. Useful for developing themes or using incomplete themes.
|
||||
# Defaults to true.
|
||||
THEME_FALLBACK =
|
||||
|
||||
# TEMPLATES_AUTO_RELOAD
|
||||
# Specifies whether Flask should check for modifications to templates and reload them automatically. Defaults to true.
|
||||
TEMPLATES_AUTO_RELOAD =
|
||||
|
||||
# SQLALCHEMY_TRACK_MODIFICATIONS
|
||||
# Automatically disabled to suppress warnings and save memory.
|
||||
# You should only enable this if you need it.
|
||||
# Defaults to false.
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS =
|
||||
|
||||
# SWAGGER_UI
|
||||
# Enable the Swagger UI endpoint at /api/v1/
|
||||
SWAGGER_UI =
|
||||
|
||||
# UPDATE_CHECK
|
||||
# Specifies whether or not CTFd will check whether or not there is a new version of CTFd. Defaults True.
|
||||
UPDATE_CHECK =
|
||||
|
||||
# APPLICATION_ROOT
|
||||
# Specifies what path CTFd is mounted under. It can be used to run CTFd in a subdirectory.
|
||||
# Example: /ctfd
|
||||
APPLICATION_ROOT =
|
||||
|
||||
# RUN_ID
|
||||
# Specifies an identifier that can be used to cache bust between deployments
|
||||
# If not set, CTFd will default to an time-based identifier
|
||||
RUN_ID =
|
||||
|
||||
# SERVER_SENT_EVENTS
|
||||
# Specifies whether or not to enable the Server-Sent Events based Notifications system.
|
||||
# Defaults to true
|
||||
SERVER_SENT_EVENTS =
|
||||
|
||||
# HTML_SANITIZATION
|
||||
# Specifies whether CTFd should sanitize HTML content
|
||||
# Defaults to false
|
||||
HTML_SANITIZATION =
|
||||
|
||||
# SQLALCHEMY_MAX_OVERFLOW
|
||||
# Specifies the max_overflow setting for SQLAlchemy's Engine
|
||||
# https://docs.sqlalchemy.org/en/13/core/engines.html#sqlalchemy.create_engine
|
||||
# https://flask-sqlalchemy.palletsprojects.com/en/2.x/config/#configuration-keys
|
||||
SQLALCHEMY_MAX_OVERFLOW =
|
||||
|
||||
# SQLALCHEMY_POOL_PRE_PING
|
||||
# Specifies the pool_pre_ping setting for SQLAlchemy's Engine
|
||||
# https://docs.sqlalchemy.org/en/13/core/engines.html#sqlalchemy.create_engine
|
||||
# https://flask-sqlalchemy.palletsprojects.com/en/2.x/config/#configuration-keys
|
||||
SQLALCHEMY_POOL_PRE_PING =
|
||||
|
||||
# EMAIL_CONFIRMATION_REQUIRE_INTERACTION
|
||||
# If enabled email confirmation links will show a page with a button for email confirmation instead of confirming immediately upon link open
|
||||
# Can help with properly confirming emails under certain link phishing protections
|
||||
# Defaults to false
|
||||
EMAIL_CONFIRMATION_REQUIRE_INTERACTION =
|
||||
|
||||
# EXTRA_CONFIGS_FORCE_TYPES =
|
||||
# Specifies config keys that will always be treated as a specific type
|
||||
# Can help with situations where CTFd's extra config processing is resulting in errors
|
||||
# Supported types are str, int, float, bool
|
||||
# Provide as CONFIG1=type,CONFIG2=type,CONFIG3=type
|
||||
EXTRA_CONFIGS_FORCE_TYPES =
|
||||
|
||||
# SAFE_MODE
|
||||
# If SAFE_MODE is enabled, CTFd will not load any plugins which may alleviate issues preventing CTFd from starting
|
||||
# Defaults to false
|
||||
SAFE_MODE =
|
||||
|
||||
[management]
|
||||
|
||||
# PRESET_ADMIN_NAME
|
||||
# A preset admin will be created by CTFd when the credentials provided below are used to login.
|
||||
# Preset admins are only created. An existing user with the same credentials are not promoted to admins.
|
||||
# This setting specifies the username for login
|
||||
PRESET_ADMIN_NAME =
|
||||
|
||||
# PRESET_ADMIN_EMAIL
|
||||
# This setting specifies the email address for the preset admin credentials
|
||||
PRESET_ADMIN_EMAIL =
|
||||
|
||||
# PRESET_ADMIN_PASSWORD
|
||||
# This setting specifies the password for the preset admin credentials
|
||||
PRESET_ADMIN_PASSWORD =
|
||||
|
||||
# PRESET_ADMIN_TOKEN
|
||||
# This setting specifies an optional API token that will be assigned to the preset admin user.
|
||||
# If the preset admin token is provided to the API, CTFd will create the associated admin user as if it was a standard login attempt
|
||||
PRESET_ADMIN_TOKEN =
|
||||
|
||||
# PRESET_CONFIGS
|
||||
# Configuration values that will be loaded into CTFd and take precedence over database settings.
|
||||
# This setting should be a JSON object containing the configuration keys and values to pre-configure CTFd instances
|
||||
# To reiterate, a configuration specified via preset cannot be overriden by an admin in the web ui
|
||||
# An example is provided below
|
||||
# PRESET_CONFIGS = {
|
||||
# "setup": true,
|
||||
# "ctf_name": "ExampleCTF"
|
||||
# }
|
||||
PRESET_CONFIGS =
|
||||
|
||||
[oauth]
|
||||
# OAUTH_CLIENT_ID
|
||||
# Register an event at https://majorleaguecyber.org/ and use the Client ID here
|
||||
OAUTH_CLIENT_ID =
|
||||
|
||||
# OAUTH_CLIENT_ID
|
||||
# Register an event at https://majorleaguecyber.org/ and use the Client Secret here
|
||||
OAUTH_CLIENT_SECRET =
|
||||
|
||||
[extra]
|
||||
# The extra section can be used to specify additional values to be loaded into CTFd's configuration
|
||||
331
CTFd/config.py
Normal file
331
CTFd/config.py
Normal file
@@ -0,0 +1,331 @@
|
||||
import configparser
|
||||
import json
|
||||
import os
|
||||
from distutils.util import strtobool
|
||||
from typing import Union
|
||||
|
||||
from sqlalchemy.engine.url import URL
|
||||
|
||||
_FORCED_EXTRA_CONFIG_TYPES = {}
|
||||
|
||||
|
||||
class EnvInterpolation(configparser.BasicInterpolation):
|
||||
"""Interpolation which expands environment variables in values."""
|
||||
|
||||
def before_get(self, parser, section, option, value, defaults):
|
||||
value = super().before_get(parser, section, option, value, defaults)
|
||||
envvar = os.getenv(option)
|
||||
if value == "" and envvar:
|
||||
return process_string_var(envvar, key=option)
|
||||
else:
|
||||
return value
|
||||
|
||||
|
||||
def process_string_var(value, key=None):
|
||||
if key in _FORCED_EXTRA_CONFIG_TYPES:
|
||||
t = _FORCED_EXTRA_CONFIG_TYPES[key]
|
||||
if t == "str":
|
||||
return str(value)
|
||||
elif t == "int":
|
||||
return int(value)
|
||||
elif t == "float":
|
||||
return float(value)
|
||||
elif t == "bool":
|
||||
return bool(strtobool(value))
|
||||
|
||||
if value == "":
|
||||
return None
|
||||
|
||||
if value.isdigit():
|
||||
return int(value)
|
||||
elif value.replace(".", "", 1).isdigit():
|
||||
return float(value)
|
||||
|
||||
try:
|
||||
return bool(strtobool(value))
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
|
||||
def process_boolean_str(value):
|
||||
if type(value) is bool:
|
||||
return value
|
||||
|
||||
if value is None:
|
||||
return False
|
||||
|
||||
if value == "":
|
||||
return None
|
||||
|
||||
return bool(strtobool(value))
|
||||
|
||||
|
||||
def empty_str_cast(value, default=None):
|
||||
if value == "":
|
||||
return default
|
||||
return value
|
||||
|
||||
|
||||
def gen_secret_key():
|
||||
# Attempt to read the secret from the secret file
|
||||
# This will fail if the secret has not been written
|
||||
try:
|
||||
with open(".ctfd_secret_key", "rb") as secret:
|
||||
key = secret.read()
|
||||
except OSError:
|
||||
key = None
|
||||
|
||||
if not key:
|
||||
key = os.urandom(64)
|
||||
# Attempt to write the secret file
|
||||
# This will fail if the filesystem is read-only
|
||||
try:
|
||||
with open(".ctfd_secret_key", "wb") as secret:
|
||||
secret.write(key)
|
||||
secret.flush()
|
||||
except OSError:
|
||||
pass
|
||||
return key
|
||||
|
||||
|
||||
config_ini = configparser.ConfigParser(interpolation=EnvInterpolation())
|
||||
config_ini.optionxform = str # Makes the key value case-insensitive
|
||||
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.ini")
|
||||
config_ini.read(path)
|
||||
|
||||
|
||||
# fmt: off
|
||||
class ServerConfig(object):
|
||||
SECRET_KEY: str = empty_str_cast(config_ini["server"]["SECRET_KEY"]) \
|
||||
or gen_secret_key()
|
||||
|
||||
DATABASE_URL: str = empty_str_cast(config_ini["server"]["DATABASE_URL"])
|
||||
if not DATABASE_URL:
|
||||
if empty_str_cast(config_ini["server"]["DATABASE_HOST"]) is not None:
|
||||
# construct URL from individual variables
|
||||
DATABASE_URL = str(URL(
|
||||
drivername=empty_str_cast(config_ini["server"]["DATABASE_PROTOCOL"]) or "mysql+pymysql",
|
||||
username=empty_str_cast(config_ini["server"]["DATABASE_USER"]) or "ctfd",
|
||||
password=empty_str_cast(config_ini["server"]["DATABASE_PASSWORD"]),
|
||||
host=empty_str_cast(config_ini["server"]["DATABASE_HOST"]),
|
||||
port=empty_str_cast(config_ini["server"]["DATABASE_PORT"]),
|
||||
database=empty_str_cast(config_ini["server"]["DATABASE_NAME"]) or "ctfd",
|
||||
))
|
||||
else:
|
||||
# default to local SQLite DB
|
||||
DATABASE_URL = f"sqlite:///{os.path.dirname(os.path.abspath(__file__))}/ctfd.db"
|
||||
|
||||
REDIS_URL: str = empty_str_cast(config_ini["server"]["REDIS_URL"])
|
||||
|
||||
REDIS_HOST: str = empty_str_cast(config_ini["server"]["REDIS_HOST"])
|
||||
REDIS_PROTOCOL: str = empty_str_cast(config_ini["server"]["REDIS_PROTOCOL"]) or "redis"
|
||||
REDIS_USER: str = empty_str_cast(config_ini["server"]["REDIS_USER"])
|
||||
REDIS_PASSWORD: str = empty_str_cast(config_ini["server"]["REDIS_PASSWORD"])
|
||||
REDIS_PORT: int = empty_str_cast(config_ini["server"]["REDIS_PORT"]) or 6379
|
||||
REDIS_DB: int = empty_str_cast(config_ini["server"]["REDIS_DB"]) or 0
|
||||
|
||||
if REDIS_URL or REDIS_HOST is None:
|
||||
CACHE_REDIS_URL = REDIS_URL
|
||||
else:
|
||||
# construct URL from individual variables
|
||||
CACHE_REDIS_URL = f"{REDIS_PROTOCOL}://"
|
||||
if REDIS_USER:
|
||||
CACHE_REDIS_URL += REDIS_USER
|
||||
if REDIS_PASSWORD:
|
||||
CACHE_REDIS_URL += f":{REDIS_PASSWORD}"
|
||||
CACHE_REDIS_URL += f"@{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}"
|
||||
|
||||
SQLALCHEMY_DATABASE_URI = DATABASE_URL
|
||||
if CACHE_REDIS_URL:
|
||||
CACHE_TYPE: str = "redis"
|
||||
else:
|
||||
CACHE_TYPE: str = "filesystem"
|
||||
CACHE_DIR: str = os.path.join(
|
||||
os.path.dirname(__file__), os.pardir, ".data", "filesystem_cache"
|
||||
)
|
||||
# Override the threshold of cached values on the filesystem. The default is 500. Don't change unless you know what you're doing.
|
||||
CACHE_THRESHOLD: int = 0
|
||||
|
||||
# === SECURITY ===
|
||||
SESSION_COOKIE_HTTPONLY: bool = config_ini["security"].getboolean("SESSION_COOKIE_HTTPONLY", fallback=True)
|
||||
|
||||
SESSION_COOKIE_SAMESITE: str = empty_str_cast(config_ini["security"]["SESSION_COOKIE_SAMESITE"]) \
|
||||
or "Lax"
|
||||
|
||||
PERMANENT_SESSION_LIFETIME: int = config_ini["security"].getint("PERMANENT_SESSION_LIFETIME") \
|
||||
or 604800
|
||||
|
||||
CROSS_ORIGIN_OPENER_POLICY: str = empty_str_cast(config_ini["security"].get("CROSS_ORIGIN_OPENER_POLICY")) \
|
||||
or "same-origin-allow-popups"
|
||||
|
||||
TRUSTED_HOSTS: list[str] | None = None
|
||||
if config_ini["security"].get("TRUSTED_HOSTS"):
|
||||
TRUSTED_HOSTS = [
|
||||
h.strip() for h in empty_str_cast(config_ini["security"].get("TRUSTED_HOSTS")).split(",")
|
||||
]
|
||||
|
||||
"""
|
||||
TRUSTED_PROXIES:
|
||||
Defines a set of regular expressions used for finding a user's IP address if the CTFd instance
|
||||
is behind a proxy. If you are running a CTF and users are on the same network as you, you may choose to remove
|
||||
some proxies from the list.
|
||||
|
||||
CTFd only uses IP addresses for cursory tracking purposes. It is ill-advised to do anything complicated based
|
||||
solely on IP addresses unless you know what you are doing.
|
||||
"""
|
||||
TRUSTED_PROXIES = [
|
||||
r"^127\.0\.0\.1$",
|
||||
# Remove the following proxies if you do not trust the local network
|
||||
# For example if you are running a CTF on your laptop and the teams are
|
||||
# all on the same network
|
||||
r"^::1$",
|
||||
r"^fc00:",
|
||||
r"^10\.",
|
||||
r"^172\.(1[6-9]|2[0-9]|3[0-1])\.",
|
||||
r"^192\.168\.",
|
||||
]
|
||||
|
||||
# === EMAIL ===
|
||||
MAILFROM_ADDR: str = config_ini["email"]["MAILFROM_ADDR"] \
|
||||
or "noreply@examplectf.com"
|
||||
|
||||
MAIL_SERVER: str = empty_str_cast(config_ini["email"]["MAIL_SERVER"])
|
||||
|
||||
MAIL_PORT: int = empty_str_cast(config_ini["email"]["MAIL_PORT"])
|
||||
|
||||
MAIL_USEAUTH: bool = process_boolean_str(config_ini["email"]["MAIL_USEAUTH"])
|
||||
|
||||
MAIL_USERNAME: str = empty_str_cast(config_ini["email"]["MAIL_USERNAME"])
|
||||
|
||||
MAIL_PASSWORD: str = empty_str_cast(config_ini["email"]["MAIL_PASSWORD"])
|
||||
|
||||
MAIL_TLS: bool = process_boolean_str(config_ini["email"]["MAIL_TLS"])
|
||||
|
||||
MAIL_SSL: bool = process_boolean_str(config_ini["email"]["MAIL_SSL"])
|
||||
|
||||
MAILSENDER_ADDR: str = empty_str_cast(config_ini["email"]["MAILSENDER_ADDR"])
|
||||
|
||||
MAILGUN_API_KEY: str = empty_str_cast(config_ini["email"]["MAILGUN_API_KEY"])
|
||||
|
||||
MAILGUN_BASE_URL: str = empty_str_cast(config_ini["email"]["MAILGUN_API_KEY"])
|
||||
|
||||
MAIL_PROVIDER: str = empty_str_cast(config_ini["email"].get("MAIL_PROVIDER"))
|
||||
|
||||
# === LOGS ===
|
||||
LOG_FOLDER: str = empty_str_cast(config_ini["logs"]["LOG_FOLDER"]) \
|
||||
or os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
|
||||
|
||||
# === UPLOADS ===
|
||||
UPLOAD_PROVIDER: str = empty_str_cast(config_ini["uploads"]["UPLOAD_PROVIDER"]) \
|
||||
or "filesystem"
|
||||
|
||||
UPLOAD_FOLDER: str = empty_str_cast(config_ini["uploads"]["UPLOAD_FOLDER"]) \
|
||||
or os.path.join(os.path.dirname(os.path.abspath(__file__)), "uploads")
|
||||
|
||||
if UPLOAD_PROVIDER == "s3":
|
||||
AWS_ACCESS_KEY_ID: str = empty_str_cast(config_ini["uploads"]["AWS_ACCESS_KEY_ID"])
|
||||
|
||||
AWS_SECRET_ACCESS_KEY: str = empty_str_cast(config_ini["uploads"]["AWS_SECRET_ACCESS_KEY"])
|
||||
|
||||
AWS_S3_BUCKET: str = empty_str_cast(config_ini["uploads"]["AWS_S3_BUCKET"])
|
||||
|
||||
AWS_S3_ENDPOINT_URL: str = empty_str_cast(config_ini["uploads"]["AWS_S3_ENDPOINT_URL"])
|
||||
|
||||
AWS_S3_REGION: str = empty_str_cast(config_ini["uploads"]["AWS_S3_REGION"])
|
||||
|
||||
AWS_S3_CUSTOM_DOMAIN: str = empty_str_cast(config_ini["uploads"].get("AWS_S3_CUSTOM_DOMAIN", ""))
|
||||
|
||||
AWS_S3_CUSTOM_PREFIX: str = empty_str_cast(config_ini["uploads"].get("AWS_S3_CUSTOM_PREFIX", ""))
|
||||
|
||||
AWS_S3_ADDRESSING_STYLE: str = empty_str_cast(config_ini["uploads"].get("AWS_S3_ADDRESSING_STYLE", ""), default="auto")
|
||||
|
||||
# === OPTIONAL ===
|
||||
REVERSE_PROXY: Union[str, bool] = empty_str_cast(config_ini["optional"]["REVERSE_PROXY"], default=False)
|
||||
|
||||
TEMPLATES_AUTO_RELOAD: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["TEMPLATES_AUTO_RELOAD"], default=True))
|
||||
|
||||
THEME_FALLBACK: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["THEME_FALLBACK"], default=True))
|
||||
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["SQLALCHEMY_TRACK_MODIFICATIONS"], default=False))
|
||||
|
||||
SWAGGER_UI: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["SWAGGER_UI"], default=False))
|
||||
|
||||
SWAGGER_UI_ENDPOINT: str = "/" if SWAGGER_UI else None
|
||||
|
||||
UPDATE_CHECK: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["UPDATE_CHECK"], default=True))
|
||||
|
||||
APPLICATION_ROOT: str = empty_str_cast(config_ini["optional"]["APPLICATION_ROOT"], default="/")
|
||||
|
||||
RUN_ID: str = empty_str_cast(config_ini["optional"].get("RUN_ID"), default=None)
|
||||
|
||||
SERVER_SENT_EVENTS: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["SERVER_SENT_EVENTS"], default=True))
|
||||
|
||||
HTML_SANITIZATION: bool = process_boolean_str(empty_str_cast(config_ini["optional"]["HTML_SANITIZATION"], default=False))
|
||||
|
||||
SAFE_MODE: bool = process_boolean_str(empty_str_cast(config_ini["optional"].get("SAFE_MODE", False), default=False))
|
||||
|
||||
EMAIL_CONFIRMATION_REQUIRE_INTERACTION: bool = process_boolean_str(empty_str_cast(config_ini["optional"].get("EMAIL_CONFIRMATION_REQUIRE_INTERACTION", False), default=False))
|
||||
|
||||
EXTRA_CONFIGS_FORCE_TYPES: str = empty_str_cast(config_ini["optional"].get("EXTRA_CONFIGS_FORCE_TYPES"), default=None)
|
||||
if EXTRA_CONFIGS_FORCE_TYPES:
|
||||
config_types = EXTRA_CONFIGS_FORCE_TYPES.split(",")
|
||||
for entry in config_types:
|
||||
k, v = entry.split("=")
|
||||
_FORCED_EXTRA_CONFIG_TYPES[k] = v
|
||||
|
||||
if DATABASE_URL.startswith("sqlite") is False:
|
||||
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||
"max_overflow": int(empty_str_cast(config_ini["optional"]["SQLALCHEMY_MAX_OVERFLOW"], default=20)), # noqa: E131
|
||||
"pool_pre_ping": empty_str_cast(config_ini["optional"]["SQLALCHEMY_POOL_PRE_PING"], default=True), # noqa: E131
|
||||
}
|
||||
|
||||
# === OAUTH ===
|
||||
OAUTH_CLIENT_ID: str = empty_str_cast(config_ini["oauth"]["OAUTH_CLIENT_ID"])
|
||||
OAUTH_CLIENT_SECRET: str = empty_str_cast(config_ini["oauth"]["OAUTH_CLIENT_SECRET"])
|
||||
|
||||
# === MANAGEMENT ===
|
||||
PRESET_ADMIN_NAME: str = empty_str_cast(config_ini["management"].get("PRESET_ADMIN_NAME", "")) if config_ini.has_section("management") else None
|
||||
PRESET_ADMIN_EMAIL: str = empty_str_cast(config_ini["management"].get("PRESET_ADMIN_EMAIL", "")) if config_ini.has_section("management") else None
|
||||
PRESET_ADMIN_PASSWORD: str = empty_str_cast(config_ini["management"].get("PRESET_ADMIN_PASSWORD", "")) if config_ini.has_section("management") else None
|
||||
PRESET_ADMIN_TOKEN: str = empty_str_cast(config_ini["management"].get("PRESET_ADMIN_TOKEN", "")) if config_ini.has_section("management") else None
|
||||
PRESET_CONFIGS: str = empty_str_cast(config_ini["management"].get("PRESET_CONFIGS", "")) if config_ini.has_section("management") else None
|
||||
if PRESET_CONFIGS and SAFE_MODE is False:
|
||||
try:
|
||||
PRESET_CONFIGS = json.loads(PRESET_CONFIGS)
|
||||
except (ValueError, TypeError):
|
||||
print("Exception occurred during PRESET_CONFIGS loading")
|
||||
PRESET_CONFIGS = {}
|
||||
else:
|
||||
PRESET_CONFIGS = {}
|
||||
|
||||
# === EXTRA ===
|
||||
# Since the configurations in section "[extra]" will be loaded later, it is not necessary to declare them here.
|
||||
# However, if you want to have some processing or checking on the value, you can still declare it here just like other configurations.
|
||||
# fmt: on
|
||||
|
||||
|
||||
class TestingConfig(ServerConfig):
|
||||
SECRET_KEY = "AAAAAAAAAAAAAAAAAAAA"
|
||||
PRESERVE_CONTEXT_ON_EXCEPTION = False
|
||||
TESTING = True
|
||||
DEBUG = True
|
||||
SQLALCHEMY_DATABASE_URI = os.getenv("TESTING_DATABASE_URL") or "sqlite://"
|
||||
MAIL_SERVER = os.getenv("TESTING_MAIL_SERVER")
|
||||
SERVER_NAME = "localhost"
|
||||
UPDATE_CHECK = False
|
||||
REDIS_URL = None
|
||||
CACHE_TYPE = "simple"
|
||||
CACHE_THRESHOLD = 500
|
||||
SAFE_MODE = True
|
||||
|
||||
|
||||
# Actually initialize ServerConfig to allow us to add more attributes on
|
||||
Config = ServerConfig()
|
||||
for k, v in config_ini.items("extra"):
|
||||
# We should only add the values that are not yet loaded in ServerConfig.
|
||||
if hasattr(Config, k):
|
||||
raise ValueError(
|
||||
f"Built-in Config {k} should not be defined in the [extra] section of config.ini"
|
||||
)
|
||||
|
||||
setattr(Config, k, v)
|
||||
63
CTFd/constants/__init__.py
Normal file
63
CTFd/constants/__init__.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from enum import Enum
|
||||
|
||||
JS_ENUMS = {}
|
||||
JINJA_ENUMS = {}
|
||||
|
||||
|
||||
class RawEnum(Enum):
|
||||
"""
|
||||
This is a customized enum class which should be used with a mixin.
|
||||
The mixin should define the types of each member.
|
||||
|
||||
For example:
|
||||
|
||||
class Colors(str, RawEnum):
|
||||
RED = "red"
|
||||
GREEN = "green"
|
||||
BLUE = "blue"
|
||||
"""
|
||||
|
||||
def __str__(self):
|
||||
return str(self._value_)
|
||||
|
||||
@classmethod
|
||||
def keys(cls):
|
||||
return list(cls.__members__.keys())
|
||||
|
||||
@classmethod
|
||||
def values(cls):
|
||||
return list(cls.__members__.values())
|
||||
|
||||
@classmethod
|
||||
def test(cls, value):
|
||||
try:
|
||||
return bool(cls(value))
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def JSEnum(cls):
|
||||
"""
|
||||
This is a decorator used to gather all Enums which should be shared with
|
||||
the CTFd front end. The JS_Enums dictionary can be taken be a script and
|
||||
compiled into a JavaScript file for use by frontend assets. JS_Enums
|
||||
should not be passed directly into Jinja. A JinjaEnum is better for that.
|
||||
"""
|
||||
if cls.__name__ not in JS_ENUMS:
|
||||
JS_ENUMS[cls.__name__] = dict(cls.__members__)
|
||||
else:
|
||||
raise KeyError("{} was already defined as a JSEnum".format(cls.__name__))
|
||||
return cls
|
||||
|
||||
|
||||
def JinjaEnum(cls):
|
||||
"""
|
||||
This is a decorator used to inject the decorated Enum into Jinja globals
|
||||
which allows you to access it from the front end. If you need to access
|
||||
an Enum from JS, a better tool to use is the JSEnum decorator.
|
||||
"""
|
||||
if cls.__name__ not in JINJA_ENUMS:
|
||||
JINJA_ENUMS[cls.__name__] = cls
|
||||
else:
|
||||
raise KeyError("{} was already defined as a JinjaEnum".format(cls.__name__))
|
||||
return cls
|
||||
79
CTFd/constants/assets.py
Normal file
79
CTFd/constants/assets.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import os
|
||||
|
||||
from flask import current_app, url_for
|
||||
|
||||
from CTFd.constants.themes import DEFAULT_THEME
|
||||
from CTFd.utils import get_asset_json
|
||||
from CTFd.utils.config import ctf_theme
|
||||
from CTFd.utils.helpers import markup
|
||||
|
||||
|
||||
class _AssetsWrapper:
|
||||
def manifest(self, theme=None, _return_none_on_load_failure=False):
|
||||
if theme is None:
|
||||
theme = ctf_theme()
|
||||
file_path = os.path.join(
|
||||
current_app.root_path, "themes", theme, "static", "manifest.json"
|
||||
)
|
||||
|
||||
if bool(current_app.config.get("THEME_FALLBACK")) and not os.path.isfile(
|
||||
file_path
|
||||
):
|
||||
theme = DEFAULT_THEME
|
||||
file_path = os.path.join(
|
||||
current_app.root_path, "themes", theme, "static", "manifest.json"
|
||||
)
|
||||
|
||||
try:
|
||||
manifest = get_asset_json(path=file_path)
|
||||
except FileNotFoundError as e:
|
||||
# This check allows us to determine if we are on a legacy theme and fallback if necessary
|
||||
if _return_none_on_load_failure:
|
||||
manifest = None
|
||||
else:
|
||||
raise e
|
||||
return manifest
|
||||
|
||||
def js(self, asset_key, theme=None, type="module", defer=False, extra=""):
|
||||
if theme is None:
|
||||
theme = ctf_theme()
|
||||
asset = self.manifest(theme=theme)[asset_key]
|
||||
entry = asset["file"]
|
||||
imports = asset.get("imports", [])
|
||||
|
||||
# Add in extra attributes. Note that type="module" imples defer
|
||||
_attrs = ""
|
||||
if type:
|
||||
_attrs = f'type="{type}" '
|
||||
if defer:
|
||||
_attrs += "defer "
|
||||
if extra:
|
||||
_attrs += extra
|
||||
|
||||
html = ""
|
||||
for i in imports:
|
||||
# TODO: Needs a better recursive solution
|
||||
i = self.manifest(theme=theme)[i]["file"]
|
||||
url = url_for("views.themes_beta", theme=theme, path=i)
|
||||
html += f'<script {_attrs} src="{url}"></script>'
|
||||
url = url_for("views.themes_beta", theme=theme, path=entry)
|
||||
html += f'<script {_attrs} src="{url}"></script>'
|
||||
return markup(html)
|
||||
|
||||
def css(self, asset_key, theme=None):
|
||||
if theme is None:
|
||||
theme = ctf_theme()
|
||||
asset = self.manifest(theme=theme)[asset_key]
|
||||
entry = asset["file"]
|
||||
url = url_for("views.themes_beta", theme=theme, path=entry)
|
||||
return markup(f'<link rel="stylesheet" href="{url}">')
|
||||
|
||||
def file(self, asset_key, theme=None):
|
||||
if theme is None:
|
||||
theme = ctf_theme()
|
||||
asset = self.manifest(theme=theme)[asset_key]
|
||||
entry = asset["file"]
|
||||
return url_for("views.themes_beta", theme=theme, path=entry)
|
||||
|
||||
|
||||
Assets = _AssetsWrapper()
|
||||
74
CTFd/constants/config.py
Normal file
74
CTFd/constants/config.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import json
|
||||
|
||||
from flask import url_for
|
||||
|
||||
# TODO: CTFd 4.0. These imports previously specified in this file but have moved. We could consider removing these imports
|
||||
from CTFd.constants.options import ( # noqa: F401
|
||||
AccountVisibilityTypes,
|
||||
ChallengeVisibilityTypes,
|
||||
ConfigTypes,
|
||||
RegistrationVisibilityTypes,
|
||||
ScoreVisibilityTypes,
|
||||
UserModeTypes,
|
||||
)
|
||||
from CTFd.utils import get_config
|
||||
|
||||
|
||||
class _ConfigsWrapper:
|
||||
def __getattr__(self, attr):
|
||||
return get_config(attr)
|
||||
|
||||
@property
|
||||
def ctf_name(self):
|
||||
return get_config("ctf_name", default="CTFd")
|
||||
|
||||
@property
|
||||
def ctf_small_icon(self):
|
||||
icon = get_config("ctf_small_icon")
|
||||
if icon:
|
||||
return url_for("views.files", path=icon)
|
||||
return url_for("views.themes", path="img/favicon.ico")
|
||||
|
||||
@property
|
||||
def theme_header(self):
|
||||
from CTFd.utils.helpers import markup
|
||||
|
||||
return markup(get_config("theme_header", default=""))
|
||||
|
||||
@property
|
||||
def theme_footer(self):
|
||||
from CTFd.utils.helpers import markup
|
||||
|
||||
return markup(get_config("theme_footer", default=""))
|
||||
|
||||
@property
|
||||
def theme_settings(self):
|
||||
try:
|
||||
return json.loads(get_config("theme_settings", default="null"))
|
||||
except json.JSONDecodeError:
|
||||
return {"error": "invalid theme_settings"}
|
||||
|
||||
@property
|
||||
def tos_or_privacy(self):
|
||||
tos = bool(get_config("tos_url") or get_config("tos_text"))
|
||||
privacy = bool(get_config("privacy_url") or get_config("privacy_text"))
|
||||
return tos or privacy
|
||||
|
||||
@property
|
||||
def tos_link(self):
|
||||
return get_config("tos_url", default=url_for("views.tos"))
|
||||
|
||||
@property
|
||||
def privacy_link(self):
|
||||
return get_config("privacy_url", default=url_for("views.privacy"))
|
||||
|
||||
@property
|
||||
def social_shares(self):
|
||||
return get_config("social_shares", default=True)
|
||||
|
||||
@property
|
||||
def challenge_ratings(self):
|
||||
return get_config("challenge_ratings", default="public")
|
||||
|
||||
|
||||
Configs = _ConfigsWrapper()
|
||||
31
CTFd/constants/email.py
Normal file
31
CTFd/constants/email.py
Normal file
@@ -0,0 +1,31 @@
|
||||
DEFAULT_VERIFICATION_EMAIL_SUBJECT = "Confirm your account for {ctf_name}"
|
||||
DEFAULT_VERIFICATION_EMAIL_BODY = (
|
||||
"Welcome to {ctf_name}!\n\n"
|
||||
"Click the following link to confirm and activate your account:\n"
|
||||
"{url}"
|
||||
"\n\n"
|
||||
"If the link is not clickable, try copying and pasting it into your browser."
|
||||
)
|
||||
DEFAULT_SUCCESSFUL_REGISTRATION_EMAIL_SUBJECT = "Successfully registered for {ctf_name}"
|
||||
DEFAULT_SUCCESSFUL_REGISTRATION_EMAIL_BODY = (
|
||||
"You've successfully registered for {ctf_name}!"
|
||||
)
|
||||
DEFAULT_USER_CREATION_EMAIL_SUBJECT = "Message from {ctf_name}"
|
||||
DEFAULT_USER_CREATION_EMAIL_BODY = (
|
||||
"A new account has been created for you for {ctf_name} at {url}. \n\n"
|
||||
"Username: {name}\n"
|
||||
"Password: {password}"
|
||||
)
|
||||
DEFAULT_PASSWORD_RESET_SUBJECT = "Password Reset Request from {ctf_name}"
|
||||
DEFAULT_PASSWORD_RESET_BODY = (
|
||||
"Did you initiate a password reset on {ctf_name}? "
|
||||
"If you didn't initiate this request you can ignore this email. \n\n"
|
||||
"Click the following link to reset your password:\n{url}\n\n"
|
||||
"If the link is not clickable, try copying and pasting it into your browser."
|
||||
)
|
||||
DEFAULT_PASSWORD_CHANGE_ALERT_SUBJECT = "Password Change Confirmation for {ctf_name}"
|
||||
DEFAULT_PASSWORD_CHANGE_ALERT_BODY = (
|
||||
"Your password for {ctf_name} has been changed.\n\n"
|
||||
"If you didn't request a password change you can reset your password here:\n{url}\n\n"
|
||||
"If the link is not clickable, try copying and pasting it into your browser."
|
||||
)
|
||||
61
CTFd/constants/languages.py
Normal file
61
CTFd/constants/languages.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from CTFd.constants import RawEnum
|
||||
|
||||
|
||||
class Languages(str, RawEnum):
|
||||
ENGLISH = "en"
|
||||
GERMAN = "de"
|
||||
POLISH = "pl"
|
||||
SPANISH = "es"
|
||||
ARABIC = "ar"
|
||||
CHINESE = "zh_CN"
|
||||
TAIWANESE = "zh_TW"
|
||||
FRENCH = "fr"
|
||||
KOREAN = "ko"
|
||||
RUSSIAN = "ru"
|
||||
BRAZILIAN_PORTUGESE = "pt_BR"
|
||||
SLOVAK = "sk"
|
||||
JAPANESE = "ja"
|
||||
ITALIAN = "it"
|
||||
VIETNAMESE = "vi"
|
||||
CATALAN = "ca"
|
||||
GREEK = "el"
|
||||
FINNISH = "fi"
|
||||
ROMANIAN = "ro"
|
||||
SLOVENIAN = "sl"
|
||||
SWEDISH = "sv"
|
||||
HEBREW = "he"
|
||||
UZBEK = "uz"
|
||||
|
||||
|
||||
LANGUAGE_NAMES = {
|
||||
"en": "English",
|
||||
"de": "Deutsch",
|
||||
"pl": "Polski",
|
||||
"es": "Español",
|
||||
"ar": "اَلْعَرَبِيَّةُ",
|
||||
"zh_CN": "简体中文",
|
||||
"zh_TW": "繁體中文",
|
||||
"fr": "Français",
|
||||
"ko": "한국어",
|
||||
"ru": "русский язык",
|
||||
"pt_BR": "Português do Brasil",
|
||||
"sk": "Slovenský jazyk",
|
||||
"ja": "日本語",
|
||||
"it": "Italiano",
|
||||
"vi": "tiếng Việt",
|
||||
"ca": "Català",
|
||||
"el": "Ελληνικά",
|
||||
"fi": "Suomi",
|
||||
"ro": "Română",
|
||||
"sl": "Slovenščina",
|
||||
"sv": "Svenska",
|
||||
"he": "עברית",
|
||||
"uz": "oʻzbekcha",
|
||||
}
|
||||
|
||||
SELECT_LANGUAGE_LIST = [("", "")] + [
|
||||
(str(lang), LANGUAGE_NAMES.get(str(lang))) for lang in Languages
|
||||
]
|
||||
|
||||
Languages.names = LANGUAGE_NAMES
|
||||
Languages.select_list = SELECT_LANGUAGE_LIST
|
||||
43
CTFd/constants/options.py
Normal file
43
CTFd/constants/options.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from CTFd.constants import JinjaEnum, RawEnum
|
||||
|
||||
|
||||
class ConfigTypes(str, RawEnum):
|
||||
CHALLENGE_VISIBILITY = "challenge_visibility"
|
||||
SCORE_VISIBILITY = "score_visibility"
|
||||
ACCOUNT_VISIBILITY = "account_visibility"
|
||||
REGISTRATION_VISIBILITY = "registration_visibility"
|
||||
|
||||
|
||||
@JinjaEnum
|
||||
class UserModeTypes(str, RawEnum):
|
||||
USERS = "users"
|
||||
TEAMS = "teams"
|
||||
|
||||
|
||||
@JinjaEnum
|
||||
class ChallengeVisibilityTypes(str, RawEnum):
|
||||
PUBLIC = "public"
|
||||
PRIVATE = "private"
|
||||
ADMINS = "admins"
|
||||
|
||||
|
||||
@JinjaEnum
|
||||
class ScoreVisibilityTypes(str, RawEnum):
|
||||
PUBLIC = "public"
|
||||
PRIVATE = "private"
|
||||
HIDDEN = "hidden"
|
||||
ADMINS = "admins"
|
||||
|
||||
|
||||
@JinjaEnum
|
||||
class AccountVisibilityTypes(str, RawEnum):
|
||||
PUBLIC = "public"
|
||||
PRIVATE = "private"
|
||||
ADMINS = "admins"
|
||||
|
||||
|
||||
@JinjaEnum
|
||||
class RegistrationVisibilityTypes(str, RawEnum):
|
||||
PUBLIC = "public"
|
||||
PRIVATE = "private"
|
||||
MLC = "mlc"
|
||||
54
CTFd/constants/plugins.py
Normal file
54
CTFd/constants/plugins.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from flask import current_app
|
||||
|
||||
from CTFd.plugins import get_admin_plugin_menu_bar, get_user_page_menu_bar
|
||||
from CTFd.utils.helpers import markup
|
||||
from CTFd.utils.plugins import get_registered_scripts, get_registered_stylesheets
|
||||
|
||||
|
||||
class _PluginWrapper:
|
||||
@property
|
||||
def scripts(self):
|
||||
application_root = current_app.config.get("APPLICATION_ROOT")
|
||||
subdir = application_root != "/"
|
||||
scripts = []
|
||||
for script in get_registered_scripts():
|
||||
if script.startswith("http"):
|
||||
scripts.append(f'<script defer src="{script}"></script>')
|
||||
elif subdir:
|
||||
scripts.append(
|
||||
f'<script defer src="{application_root}/{script}"></script>'
|
||||
)
|
||||
else:
|
||||
scripts.append(f'<script defer src="{script}"></script>')
|
||||
return markup("\n".join(scripts))
|
||||
|
||||
@property
|
||||
def styles(self):
|
||||
application_root = current_app.config.get("APPLICATION_ROOT")
|
||||
subdir = application_root != "/"
|
||||
_styles = []
|
||||
for stylesheet in get_registered_stylesheets():
|
||||
if stylesheet.startswith("http"):
|
||||
_styles.append(
|
||||
f'<link rel="stylesheet" type="text/css" href="{stylesheet}">'
|
||||
)
|
||||
elif subdir:
|
||||
_styles.append(
|
||||
f'<link rel="stylesheet" type="text/css" href="{application_root}/{stylesheet}">'
|
||||
)
|
||||
else:
|
||||
_styles.append(
|
||||
f'<link rel="stylesheet" type="text/css" href="{stylesheet}">'
|
||||
)
|
||||
return markup("\n".join(_styles))
|
||||
|
||||
@property
|
||||
def user_menu_pages(self):
|
||||
return get_user_page_menu_bar()
|
||||
|
||||
@property
|
||||
def admin_menu_pages(self):
|
||||
return get_admin_plugin_menu_bar()
|
||||
|
||||
|
||||
Plugins = _PluginWrapper()
|
||||
18
CTFd/constants/sessions.py
Normal file
18
CTFd/constants/sessions.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from flask import session
|
||||
|
||||
|
||||
class _SessionWrapper:
|
||||
@property
|
||||
def id(self):
|
||||
return session.get("id", 0)
|
||||
|
||||
@property
|
||||
def nonce(self):
|
||||
return session.get("nonce")
|
||||
|
||||
@property
|
||||
def hash(self):
|
||||
return session.get("hash")
|
||||
|
||||
|
||||
Session = _SessionWrapper()
|
||||
21
CTFd/constants/setup.py
Normal file
21
CTFd/constants/setup.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from CTFd.constants.options import (
|
||||
AccountVisibilityTypes,
|
||||
ChallengeVisibilityTypes,
|
||||
RegistrationVisibilityTypes,
|
||||
ScoreVisibilityTypes,
|
||||
UserModeTypes,
|
||||
)
|
||||
from CTFd.constants.themes import DEFAULT_THEME
|
||||
|
||||
DEFAULTS = {
|
||||
# General Settings
|
||||
"ctf_name": "CTFd",
|
||||
"user_mode": UserModeTypes.USERS,
|
||||
# Visual/Style Settings
|
||||
"ctf_theme": DEFAULT_THEME,
|
||||
# Visibility Settings
|
||||
"challenge_visibility": ChallengeVisibilityTypes.PRIVATE,
|
||||
"registration_visibility": RegistrationVisibilityTypes.PUBLIC,
|
||||
"score_visibility": ScoreVisibilityTypes.PUBLIC,
|
||||
"account_visibility": AccountVisibilityTypes.PUBLIC,
|
||||
}
|
||||
14
CTFd/constants/static.py
Normal file
14
CTFd/constants/static.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from CTFd.constants import JinjaEnum, RawEnum
|
||||
|
||||
|
||||
@JinjaEnum
|
||||
class CacheKeys(str, RawEnum):
|
||||
PUBLIC_SCOREBOARD_TABLE = "public_scoreboard_table"
|
||||
|
||||
|
||||
# Placeholder object. Not used, just imported to force initialization of any Enums here
|
||||
class _StaticsWrapper:
|
||||
pass
|
||||
|
||||
|
||||
Static = _StaticsWrapper()
|
||||
46
CTFd/constants/teams.py
Normal file
46
CTFd/constants/teams.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from collections import namedtuple
|
||||
|
||||
# TODO: CTFd 4.0. Consider changing to a dataclass
|
||||
TeamAttrsFields = [
|
||||
"id",
|
||||
"oauth_id",
|
||||
"name",
|
||||
"email",
|
||||
"secret",
|
||||
"website",
|
||||
"affiliation",
|
||||
"country",
|
||||
"bracket_id",
|
||||
"hidden",
|
||||
"banned",
|
||||
"captain_id",
|
||||
"created",
|
||||
]
|
||||
TeamAttrs = namedtuple(
|
||||
"TeamAttrs",
|
||||
TeamAttrsFields,
|
||||
defaults=(None,) * len(TeamAttrsFields),
|
||||
)
|
||||
|
||||
|
||||
class _TeamAttrsWrapper:
|
||||
def __getattr__(self, attr):
|
||||
from CTFd.utils.user import get_current_team_attrs
|
||||
|
||||
attrs = get_current_team_attrs()
|
||||
return getattr(attrs, attr, None)
|
||||
|
||||
@property
|
||||
def place(self):
|
||||
from CTFd.utils.user import get_team_place
|
||||
|
||||
return get_team_place(team_id=self.id)
|
||||
|
||||
@property
|
||||
def score(self):
|
||||
from CTFd.utils.user import get_team_score
|
||||
|
||||
return get_team_score(team_id=self.id)
|
||||
|
||||
|
||||
Team = _TeamAttrsWrapper()
|
||||
2
CTFd/constants/themes.py
Normal file
2
CTFd/constants/themes.py
Normal file
@@ -0,0 +1,2 @@
|
||||
ADMIN_THEME = "admin"
|
||||
DEFAULT_THEME = "core"
|
||||
48
CTFd/constants/users.py
Normal file
48
CTFd/constants/users.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from collections import namedtuple
|
||||
|
||||
# TODO: CTFd 4.0. Consider changing to a dataclass
|
||||
UserAttrsFields = [
|
||||
"id",
|
||||
"oauth_id",
|
||||
"name",
|
||||
"email",
|
||||
"type",
|
||||
"secret",
|
||||
"website",
|
||||
"affiliation",
|
||||
"country",
|
||||
"bracket_id",
|
||||
"hidden",
|
||||
"banned",
|
||||
"verified",
|
||||
"language",
|
||||
"team_id",
|
||||
"created",
|
||||
"change_password",
|
||||
]
|
||||
UserAttrs = namedtuple(
|
||||
"UserAttrs", UserAttrsFields, defaults=(None,) * len(UserAttrsFields)
|
||||
)
|
||||
|
||||
|
||||
class _UserAttrsWrapper:
|
||||
def __getattr__(self, attr):
|
||||
from CTFd.utils.user import get_current_user_attrs
|
||||
|
||||
attrs = get_current_user_attrs()
|
||||
return getattr(attrs, attr, None)
|
||||
|
||||
@property
|
||||
def place(self):
|
||||
from CTFd.utils.user import get_user_place
|
||||
|
||||
return get_user_place(user_id=self.id)
|
||||
|
||||
@property
|
||||
def score(self):
|
||||
from CTFd.utils.user import get_user_score
|
||||
|
||||
return get_user_score(user_id=self.id)
|
||||
|
||||
|
||||
User = _UserAttrsWrapper()
|
||||
21
CTFd/errors.py
Normal file
21
CTFd/errors.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import jinja2.exceptions
|
||||
from flask import render_template
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
|
||||
|
||||
def render_error(error):
|
||||
if (
|
||||
isinstance(error, InternalServerError)
|
||||
and error.description == InternalServerError.description
|
||||
):
|
||||
error.description = "An Internal Server Error has occurred"
|
||||
try:
|
||||
return (
|
||||
render_template(
|
||||
"errors/{}.html".format(error.code),
|
||||
error=error.description,
|
||||
),
|
||||
error.code,
|
||||
)
|
||||
except jinja2.exceptions.TemplateNotFound:
|
||||
return error.get_response()
|
||||
26
CTFd/events/__init__.py
Normal file
26
CTFd/events/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from flask import Blueprint, Response, current_app, stream_with_context
|
||||
|
||||
from CTFd.models import db
|
||||
from CTFd.utils import get_app_config
|
||||
from CTFd.utils.decorators import authed_only, ratelimit
|
||||
|
||||
events = Blueprint("events", __name__)
|
||||
|
||||
|
||||
@events.route("/events")
|
||||
@authed_only
|
||||
@ratelimit(method="GET", limit=150, interval=60)
|
||||
def subscribe():
|
||||
@stream_with_context
|
||||
def gen():
|
||||
for event in current_app.events_manager.subscribe():
|
||||
yield str(event)
|
||||
|
||||
enabled = get_app_config("SERVER_SENT_EVENTS")
|
||||
if enabled is False:
|
||||
return ("", 204)
|
||||
|
||||
# Close the db session to avoid OperationalError with MySQL connection errors
|
||||
db.session.close()
|
||||
|
||||
return Response(gen(), mimetype="text/event-stream")
|
||||
14
CTFd/exceptions/__init__.py
Normal file
14
CTFd/exceptions/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
class UserNotFoundException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UserTokenExpiredException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class TeamTokenExpiredException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class TeamTokenInvalidException(Exception):
|
||||
pass
|
||||
10
CTFd/exceptions/challenges.py
Normal file
10
CTFd/exceptions/challenges.py
Normal file
@@ -0,0 +1,10 @@
|
||||
class ChallengeCreateException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ChallengeUpdateException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ChallengeSolveException(Exception):
|
||||
pass
|
||||
6
CTFd/exceptions/email.py
Normal file
6
CTFd/exceptions/email.py
Normal file
@@ -0,0 +1,6 @@
|
||||
class UserConfirmTokenInvalidException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UserResetPasswordTokenInvalidException(Exception):
|
||||
pass
|
||||
88
CTFd/fonts/OFL.txt
Normal file
88
CTFd/fonts/OFL.txt
Normal file
@@ -0,0 +1,88 @@
|
||||
Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans)
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font
|
||||
creation efforts of academic and linguistic communities, and to
|
||||
provide a free and open framework in which fonts may be shared and
|
||||
improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to
|
||||
any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software
|
||||
components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to,
|
||||
deleting, or substituting -- in part or in whole -- any of the
|
||||
components of the Original Version, by changing formats or by porting
|
||||
the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||
modify, redistribute, and sell modified and unmodified copies of the
|
||||
Font Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the
|
||||
corresponding Copyright Holder. This restriction only applies to the
|
||||
primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created using
|
||||
the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
CTFd/fonts/OpenSans-Bold.ttf
Normal file
BIN
CTFd/fonts/OpenSans-Bold.ttf
Normal file
Binary file not shown.
51
CTFd/forms/__init__.py
Normal file
51
CTFd/forms/__init__.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from wtforms import Form
|
||||
from wtforms.csrf.core import CSRF
|
||||
|
||||
|
||||
class CTFdCSRF(CSRF):
|
||||
def generate_csrf_token(self, csrf_token_field):
|
||||
from flask import session
|
||||
|
||||
return session.get("nonce")
|
||||
|
||||
|
||||
class BaseForm(Form):
|
||||
class Meta:
|
||||
csrf = True
|
||||
csrf_class = CTFdCSRF
|
||||
csrf_field_name = "nonce"
|
||||
|
||||
|
||||
class _FormsWrapper:
|
||||
pass
|
||||
|
||||
|
||||
Forms = _FormsWrapper()
|
||||
|
||||
from CTFd.forms import auth # noqa: I001 isort:skip
|
||||
from CTFd.forms import self # noqa: I001 isort:skip
|
||||
from CTFd.forms import teams # noqa: I001 isort:skip
|
||||
from CTFd.forms import setup # noqa: I001 isort:skip
|
||||
from CTFd.forms import submissions # noqa: I001 isort:skip
|
||||
from CTFd.forms import users # noqa: I001 isort:skip
|
||||
from CTFd.forms import challenges # noqa: I001 isort:skip
|
||||
from CTFd.forms import language # noqa: I001 isort:skip
|
||||
from CTFd.forms import notifications # noqa: I001 isort:skip
|
||||
from CTFd.forms import config # noqa: I001 isort:skip
|
||||
from CTFd.forms import pages # noqa: I001 isort:skip
|
||||
from CTFd.forms import awards # noqa: I001 isort:skip
|
||||
from CTFd.forms import email # noqa: I001 isort:skip
|
||||
|
||||
Forms.auth = auth
|
||||
Forms.self = self
|
||||
Forms.teams = teams
|
||||
Forms.setup = setup
|
||||
Forms.submissions = submissions
|
||||
Forms.users = users
|
||||
Forms.challenges = challenges
|
||||
Forms.language = language
|
||||
Forms.notifications = notifications
|
||||
Forms.config = config
|
||||
Forms.pages = pages
|
||||
Forms.awards = awards
|
||||
Forms.email = email
|
||||
88
CTFd/forms/auth.py
Normal file
88
CTFd/forms/auth.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from flask_babel import lazy_gettext as _l
|
||||
from wtforms import PasswordField, StringField
|
||||
from wtforms.fields.html5 import EmailField
|
||||
from wtforms.validators import InputRequired
|
||||
|
||||
from CTFd.forms import BaseForm
|
||||
from CTFd.forms.fields import SubmitField
|
||||
from CTFd.forms.users import (
|
||||
attach_custom_user_fields,
|
||||
attach_registration_code_field,
|
||||
attach_user_bracket_field,
|
||||
build_custom_user_fields,
|
||||
build_registration_code_field,
|
||||
build_user_bracket_field,
|
||||
)
|
||||
from CTFd.utils import get_config
|
||||
|
||||
|
||||
def RegistrationForm(*args, **kwargs):
|
||||
password_min_length = int(get_config("password_min_length", default=0))
|
||||
password_description = _l("Password used to log into your account")
|
||||
if password_min_length:
|
||||
password_description += _l(
|
||||
f" (Must be at least {password_min_length} characters)"
|
||||
)
|
||||
|
||||
class _RegistrationForm(BaseForm):
|
||||
name = StringField(
|
||||
_l("User Name"),
|
||||
description="Your username on the site",
|
||||
validators=[InputRequired()],
|
||||
render_kw={"autofocus": True},
|
||||
)
|
||||
email = EmailField(
|
||||
_l("Email"),
|
||||
description="Never shown to the public",
|
||||
validators=[InputRequired()],
|
||||
)
|
||||
password = PasswordField(
|
||||
_l("Password"),
|
||||
description=password_description,
|
||||
validators=[InputRequired()],
|
||||
)
|
||||
submit = SubmitField(_l("Submit"))
|
||||
|
||||
@property
|
||||
def extra(self):
|
||||
return (
|
||||
build_custom_user_fields(
|
||||
self, include_entries=False, blacklisted_items=()
|
||||
)
|
||||
+ build_registration_code_field(self)
|
||||
+ build_user_bracket_field(self)
|
||||
)
|
||||
|
||||
attach_custom_user_fields(_RegistrationForm)
|
||||
attach_registration_code_field(_RegistrationForm)
|
||||
attach_user_bracket_field(_RegistrationForm)
|
||||
|
||||
return _RegistrationForm(*args, **kwargs)
|
||||
|
||||
|
||||
class LoginForm(BaseForm):
|
||||
name = StringField(
|
||||
_l("User Name or Email"),
|
||||
validators=[InputRequired()],
|
||||
render_kw={"autofocus": True},
|
||||
)
|
||||
password = PasswordField(_l("Password"), validators=[InputRequired()])
|
||||
submit = SubmitField(_l("Submit"))
|
||||
|
||||
|
||||
class ConfirmForm(BaseForm):
|
||||
submit = SubmitField(_l("Send Confirmation Email"))
|
||||
|
||||
|
||||
class ResetPasswordRequestForm(BaseForm):
|
||||
email = EmailField(
|
||||
_l("Email"), validators=[InputRequired()], render_kw={"autofocus": True}
|
||||
)
|
||||
submit = SubmitField(_l("Submit"))
|
||||
|
||||
|
||||
class ResetPasswordForm(BaseForm):
|
||||
password = PasswordField(
|
||||
_l("Password"), validators=[InputRequired()], render_kw={"autofocus": True}
|
||||
)
|
||||
submit = SubmitField(_l("Submit"))
|
||||
30
CTFd/forms/awards.py
Normal file
30
CTFd/forms/awards.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from wtforms import RadioField, StringField, TextAreaField
|
||||
from wtforms.fields.html5 import IntegerField
|
||||
|
||||
from CTFd.forms import BaseForm
|
||||
from CTFd.forms.fields import SubmitField
|
||||
|
||||
|
||||
class AwardCreationForm(BaseForm):
|
||||
name = StringField("Name")
|
||||
value = IntegerField("Value")
|
||||
category = StringField("Category")
|
||||
description = TextAreaField("Description")
|
||||
submit = SubmitField("Create")
|
||||
icon = RadioField(
|
||||
"Icon",
|
||||
choices=[
|
||||
("", "None"),
|
||||
("shield", "Shield"),
|
||||
("bug", "Bug"),
|
||||
("crown", "Crown"),
|
||||
("crosshairs", "Crosshairs"),
|
||||
("ban", "Ban"),
|
||||
("lightning", "Lightning"),
|
||||
("skull", "Skull"),
|
||||
("brain", "Brain"),
|
||||
("code", "Code"),
|
||||
("cowboy", "Cowboy"),
|
||||
("angry", "Angry"),
|
||||
],
|
||||
)
|
||||
30
CTFd/forms/challenges.py
Normal file
30
CTFd/forms/challenges.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from wtforms import MultipleFileField, SelectField, StringField
|
||||
from wtforms.validators import InputRequired
|
||||
|
||||
from CTFd.forms import BaseForm
|
||||
from CTFd.forms.fields import SubmitField
|
||||
|
||||
|
||||
class ChallengeSearchForm(BaseForm):
|
||||
field = SelectField(
|
||||
"Search Field",
|
||||
choices=[
|
||||
("name", "Name"),
|
||||
("id", "ID"),
|
||||
("category", "Category"),
|
||||
("type", "Type"),
|
||||
],
|
||||
default="name",
|
||||
validators=[InputRequired()],
|
||||
)
|
||||
q = StringField("Parameter", validators=[InputRequired()])
|
||||
submit = SubmitField("Search")
|
||||
|
||||
|
||||
class ChallengeFilesUploadForm(BaseForm):
|
||||
file = MultipleFileField(
|
||||
"Upload Files",
|
||||
description="Attach multiple files using Control+Click or Cmd+Click.",
|
||||
validators=[InputRequired()],
|
||||
)
|
||||
submit = SubmitField("Upload")
|
||||
344
CTFd/forms/config.py
Normal file
344
CTFd/forms/config.py
Normal file
@@ -0,0 +1,344 @@
|
||||
from wtforms import BooleanField, FileField, SelectField, StringField, TextAreaField
|
||||
from wtforms.fields.html5 import IntegerField, URLField
|
||||
from wtforms.widgets.html5 import NumberInput
|
||||
|
||||
from CTFd.constants.config import (
|
||||
AccountVisibilityTypes,
|
||||
ChallengeVisibilityTypes,
|
||||
RegistrationVisibilityTypes,
|
||||
ScoreVisibilityTypes,
|
||||
)
|
||||
from CTFd.constants.email import (
|
||||
DEFAULT_PASSWORD_CHANGE_ALERT_BODY,
|
||||
DEFAULT_PASSWORD_CHANGE_ALERT_SUBJECT,
|
||||
DEFAULT_PASSWORD_RESET_BODY,
|
||||
DEFAULT_PASSWORD_RESET_SUBJECT,
|
||||
DEFAULT_SUCCESSFUL_REGISTRATION_EMAIL_BODY,
|
||||
DEFAULT_SUCCESSFUL_REGISTRATION_EMAIL_SUBJECT,
|
||||
DEFAULT_USER_CREATION_EMAIL_BODY,
|
||||
DEFAULT_USER_CREATION_EMAIL_SUBJECT,
|
||||
DEFAULT_VERIFICATION_EMAIL_BODY,
|
||||
DEFAULT_VERIFICATION_EMAIL_SUBJECT,
|
||||
)
|
||||
from CTFd.constants.languages import SELECT_LANGUAGE_LIST
|
||||
from CTFd.forms import BaseForm
|
||||
from CTFd.forms.fields import SubmitField
|
||||
from CTFd.utils.csv import get_dumpable_tables
|
||||
from CTFd.utils.social import BASE_TEMPLATE
|
||||
|
||||
|
||||
class ResetInstanceForm(BaseForm):
|
||||
accounts = BooleanField(
|
||||
"Accounts",
|
||||
description="Deletes all user and team accounts and their associated information",
|
||||
)
|
||||
submissions = BooleanField(
|
||||
"Submissions",
|
||||
description="Deletes all records that accounts gained points or took an action",
|
||||
)
|
||||
challenges = BooleanField(
|
||||
"Challenges", description="Deletes all challenges and associated data"
|
||||
)
|
||||
pages = BooleanField(
|
||||
"Pages", description="Deletes all pages and their associated files"
|
||||
)
|
||||
notifications = BooleanField(
|
||||
"Notifications", description="Deletes all notifications"
|
||||
)
|
||||
submit = SubmitField("Reset CTF")
|
||||
|
||||
|
||||
class AccountSettingsForm(BaseForm):
|
||||
domain_whitelist = StringField(
|
||||
"Email Domain Allowlist",
|
||||
description="Comma-seperated list of allowable email domains which users can register under (e.g. examplectf.com, example.com, *.example.com)",
|
||||
)
|
||||
domain_blacklist = StringField(
|
||||
"Email Domain Blocklist",
|
||||
description="Comma-seperated list of disallowed email domains which users cannot register under (e.g. examplectf.com, example.com, *.example.com)",
|
||||
)
|
||||
team_creation = SelectField(
|
||||
"Team Creation",
|
||||
description="Control whether users can create their own teams (Teams mode only)",
|
||||
choices=[("true", "Enabled"), ("false", "Disabled")],
|
||||
default="true",
|
||||
)
|
||||
team_size = IntegerField(
|
||||
widget=NumberInput(min=0),
|
||||
description="Maximum number of users per team (Teams mode only)",
|
||||
)
|
||||
num_teams = IntegerField(
|
||||
"Maximum Number of Teams",
|
||||
widget=NumberInput(min=0),
|
||||
description="Maximum number of teams allowed to register with this CTF (Teams mode only)",
|
||||
)
|
||||
num_users = IntegerField(
|
||||
"Maximum Number of Users",
|
||||
widget=NumberInput(min=0),
|
||||
description="Maximum number of user accounts allowed to register with this CTF",
|
||||
)
|
||||
password_min_length = IntegerField(
|
||||
"Minimum Password Length for Users",
|
||||
widget=NumberInput(min=0),
|
||||
description="Minimum Password Length for Users",
|
||||
)
|
||||
verify_emails = SelectField(
|
||||
"Verify Emails",
|
||||
description="Control whether users must confirm their email addresses before playing",
|
||||
choices=[("true", "Enabled"), ("false", "Disabled")],
|
||||
default="false",
|
||||
)
|
||||
team_disbanding = SelectField(
|
||||
"Team Disbanding",
|
||||
description="Control whether team captains are allowed to disband their own teams",
|
||||
choices=[
|
||||
("inactive_only", "Enabled for Inactive Teams"),
|
||||
("disabled", "Disabled"),
|
||||
],
|
||||
default="inactive_only",
|
||||
)
|
||||
name_changes = SelectField(
|
||||
"Name Changes",
|
||||
description="Control whether users and teams can change their names",
|
||||
choices=[("true", "Enabled"), ("false", "Disabled")],
|
||||
default="true",
|
||||
)
|
||||
incorrect_submissions_per_min = IntegerField(
|
||||
"Incorrect Submissions per Minute",
|
||||
widget=NumberInput(min=1),
|
||||
description="Number of submissions allowed per minute for flag bruteforce protection (default: 10)",
|
||||
)
|
||||
|
||||
submit = SubmitField("Update")
|
||||
|
||||
|
||||
class ExportCSVForm(BaseForm):
|
||||
table = SelectField("Database Table", choices=get_dumpable_tables())
|
||||
submit = SubmitField("Download CSV")
|
||||
|
||||
|
||||
class ImportCSVForm(BaseForm):
|
||||
csv_type = SelectField(
|
||||
"CSV Type",
|
||||
choices=[("users", "Users"), ("teams", "Teams"), ("challenges", "Challenges")],
|
||||
description="Type of CSV data",
|
||||
)
|
||||
csv_file = FileField("CSV File", description="CSV file contents")
|
||||
|
||||
|
||||
class SocialSettingsForm(BaseForm):
|
||||
social_shares = SelectField(
|
||||
"Social Shares",
|
||||
description="Enable or Disable social sharing links for challenge solves",
|
||||
choices=[("true", "Enabled"), ("false", "Disabled")],
|
||||
default="true",
|
||||
)
|
||||
social_share_solve_template = TextAreaField(
|
||||
"Social Share Solve Template",
|
||||
description="HTML for Share Template",
|
||||
default=BASE_TEMPLATE,
|
||||
)
|
||||
submit = SubmitField("Update")
|
||||
|
||||
|
||||
class LegalSettingsForm(BaseForm):
|
||||
tos_url = URLField(
|
||||
"Terms of Service URL",
|
||||
description="External URL to a Terms of Service document hosted elsewhere",
|
||||
)
|
||||
tos_text = TextAreaField(
|
||||
"Terms of Service",
|
||||
description="Text shown on the Terms of Service page",
|
||||
)
|
||||
privacy_url = URLField(
|
||||
"Privacy Policy URL",
|
||||
description="External URL to a Privacy Policy document hosted elsewhere",
|
||||
)
|
||||
privacy_text = TextAreaField(
|
||||
"Privacy Policy",
|
||||
description="Text shown on the Privacy Policy page",
|
||||
)
|
||||
submit = SubmitField("Update")
|
||||
|
||||
|
||||
class ChallengeSettingsForm(BaseForm):
|
||||
view_self_submissions = SelectField(
|
||||
"View Self Submissions",
|
||||
description="Allow users to view their previous submissions",
|
||||
choices=[("true", "Enabled"), ("false", "Disabled")],
|
||||
default="false",
|
||||
)
|
||||
max_attempts_behavior = SelectField(
|
||||
"Max Attempts Behavior",
|
||||
description="Set Max Attempts behavior to be a lockout or a timeout",
|
||||
choices=[("lockout", "lockout"), ("timeout", "timeout")],
|
||||
default="lockout",
|
||||
)
|
||||
max_attempts_timeout = IntegerField(
|
||||
"Max Attempts Timeout Duration",
|
||||
description="How long the timeout lasts in seconds for max attempts (if set to timeout). Default is 300 seconds",
|
||||
default=300,
|
||||
)
|
||||
hints_free_public_access = SelectField(
|
||||
"Hints Free Public Access",
|
||||
description="Control whether users must be logged in to see free hints (hints without cost or requirements)",
|
||||
choices=[("true", "Enabled"), ("false", "Disabled")],
|
||||
default="false",
|
||||
)
|
||||
challenge_ratings = SelectField(
|
||||
"Challenge Ratings",
|
||||
description="Control who can see and submit challenge ratings",
|
||||
choices=[
|
||||
("public", "Public (users can submit ratings and see aggregated ratings)"),
|
||||
(
|
||||
"private",
|
||||
"Private (users can submit ratings but cannot see aggregated ratings)",
|
||||
),
|
||||
(
|
||||
"disabled",
|
||||
"Disabled (users cannot submit ratings or see aggregated ratings)",
|
||||
),
|
||||
],
|
||||
default="public",
|
||||
)
|
||||
|
||||
|
||||
class VisibilitySettingsForm(BaseForm):
|
||||
challenge_visibility = SelectField(
|
||||
"Challenge Visibility",
|
||||
description="Control whether users must be logged in to see challenges",
|
||||
choices=[
|
||||
(ChallengeVisibilityTypes.PUBLIC, "Public"),
|
||||
(ChallengeVisibilityTypes.PRIVATE, "Private"),
|
||||
(ChallengeVisibilityTypes.ADMINS, "Admins Only"),
|
||||
],
|
||||
default=ChallengeVisibilityTypes.PRIVATE,
|
||||
)
|
||||
account_visibility = SelectField(
|
||||
"Account Visibility",
|
||||
description="Control whether accounts (users & teams) are shown to everyone, only to authenticated users, or only to admins",
|
||||
choices=[
|
||||
(AccountVisibilityTypes.PUBLIC, "Public"),
|
||||
(AccountVisibilityTypes.PRIVATE, "Private"),
|
||||
(AccountVisibilityTypes.ADMINS, "Admins Only"),
|
||||
],
|
||||
default=AccountVisibilityTypes.PUBLIC,
|
||||
)
|
||||
score_visibility = SelectField(
|
||||
"Score Visibility",
|
||||
description="Control whether solves/score are shown to the public, to logged in users, hidden to all non-admins, or only shown to admins",
|
||||
choices=[
|
||||
(ScoreVisibilityTypes.PUBLIC, "Public"),
|
||||
(ScoreVisibilityTypes.PRIVATE, "Private"),
|
||||
(ScoreVisibilityTypes.HIDDEN, "Hidden"),
|
||||
(ScoreVisibilityTypes.ADMINS, "Admins Only"),
|
||||
],
|
||||
default=ScoreVisibilityTypes.PUBLIC,
|
||||
)
|
||||
registration_visibility = SelectField(
|
||||
"Registration Visibility",
|
||||
description="Control whether registration is enabled for everyone or disabled",
|
||||
choices=[
|
||||
(RegistrationVisibilityTypes.PUBLIC, "Public"),
|
||||
(RegistrationVisibilityTypes.PRIVATE, "Private"),
|
||||
(RegistrationVisibilityTypes.MLC, "MajorLeagueCyber Only"),
|
||||
],
|
||||
default=RegistrationVisibilityTypes.PUBLIC,
|
||||
)
|
||||
|
||||
|
||||
class LocalizationForm(BaseForm):
|
||||
default_locale = SelectField(
|
||||
"Default Language",
|
||||
description="Language to use if a user does not specify a language in their account settings. By default, CTFd will auto-detect the user's preferred language.",
|
||||
choices=SELECT_LANGUAGE_LIST,
|
||||
)
|
||||
|
||||
|
||||
class EmailSettingsForm(BaseForm):
|
||||
# Mail Server Settings
|
||||
mailfrom_addr = StringField(
|
||||
"Mail From Address", description="Email address used to send email"
|
||||
)
|
||||
mail_server = StringField(
|
||||
"Mail Server Address",
|
||||
description="Change the email server used by CTFd to send email",
|
||||
)
|
||||
mail_port = IntegerField(
|
||||
"Mail Server Port",
|
||||
widget=NumberInput(min=1, max=65535),
|
||||
description="Mail Server Port",
|
||||
)
|
||||
mail_useauth = BooleanField("Use Mail Server Username and Password")
|
||||
mail_username = StringField("Username", description="Mail Server Account Username")
|
||||
mail_password = StringField("Password", description="Mail Server Account Password")
|
||||
mail_ssl = BooleanField("TLS/SSL")
|
||||
mail_tls = BooleanField("STARTTLS")
|
||||
|
||||
# Mailgun Settings (deprecated)
|
||||
mailgun_base_url = StringField(
|
||||
"Mailgun API Base URL", description="Mailgun API Base URL"
|
||||
)
|
||||
mailgun_api_key = StringField("Mailgun API Key", description="Mailgun API Key")
|
||||
|
||||
# Registration Email
|
||||
successful_registration_email_subject = StringField(
|
||||
"Subject",
|
||||
description="Subject line for registration confirmation email",
|
||||
default=DEFAULT_SUCCESSFUL_REGISTRATION_EMAIL_SUBJECT,
|
||||
)
|
||||
successful_registration_email_body = TextAreaField(
|
||||
"Body",
|
||||
description="Email body sent to users after they've finished registering",
|
||||
default=DEFAULT_SUCCESSFUL_REGISTRATION_EMAIL_BODY,
|
||||
)
|
||||
|
||||
# Verification Email
|
||||
verification_email_subject = StringField(
|
||||
"Subject",
|
||||
description="Subject line for account verification email",
|
||||
default=DEFAULT_VERIFICATION_EMAIL_SUBJECT,
|
||||
)
|
||||
verification_email_body = TextAreaField(
|
||||
"Body",
|
||||
description="Email body sent to users to confirm their account at the address they provided during registration",
|
||||
default=DEFAULT_VERIFICATION_EMAIL_BODY,
|
||||
)
|
||||
|
||||
# Account Details Email
|
||||
user_creation_email_subject = StringField(
|
||||
"Subject",
|
||||
description="Subject line for new account details email",
|
||||
default=DEFAULT_USER_CREATION_EMAIL_SUBJECT,
|
||||
)
|
||||
user_creation_email_body = TextAreaField(
|
||||
"Body",
|
||||
description="Email body sent to new users (manually created by an admin) with their initial account details",
|
||||
default=DEFAULT_USER_CREATION_EMAIL_BODY,
|
||||
)
|
||||
|
||||
# Password Reset Email
|
||||
password_reset_subject = StringField(
|
||||
"Subject",
|
||||
description="Subject line for password reset request email",
|
||||
default=DEFAULT_PASSWORD_RESET_SUBJECT,
|
||||
)
|
||||
password_reset_body = TextAreaField(
|
||||
"Body",
|
||||
description="Email body sent when a user requests a password reset",
|
||||
default=DEFAULT_PASSWORD_RESET_BODY,
|
||||
)
|
||||
|
||||
# Password Reset Confirmation Email
|
||||
password_change_alert_subject = StringField(
|
||||
"Subject",
|
||||
description="Subject line for password reset confirmation email",
|
||||
default=DEFAULT_PASSWORD_CHANGE_ALERT_SUBJECT,
|
||||
)
|
||||
password_change_alert_body = TextAreaField(
|
||||
"Body",
|
||||
description="Email body sent when a user successfully resets their password",
|
||||
default=DEFAULT_PASSWORD_CHANGE_ALERT_BODY,
|
||||
)
|
||||
|
||||
submit = SubmitField("Update")
|
||||
10
CTFd/forms/email.py
Normal file
10
CTFd/forms/email.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from wtforms import TextAreaField
|
||||
from wtforms.validators import InputRequired
|
||||
|
||||
from CTFd.forms import BaseForm
|
||||
from CTFd.forms.fields import SubmitField
|
||||
|
||||
|
||||
class SendEmailForm(BaseForm):
|
||||
text = TextAreaField("Message", validators=[InputRequired()])
|
||||
submit = SubmitField("Send")
|
||||
17
CTFd/forms/fields.py
Normal file
17
CTFd/forms/fields.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from wtforms import SubmitField as _SubmitField
|
||||
|
||||
|
||||
class SubmitField(_SubmitField):
|
||||
"""
|
||||
This custom SubmitField exists because wtforms is dumb.
|
||||
|
||||
See https://github.com/wtforms/wtforms/issues/205, https://github.com/wtforms/wtforms/issues/36
|
||||
The .submit() handler in JS will break if the form has an input with the name or id of "submit" so submit fields need to be changed.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
name = kwargs.pop("name", "_submit")
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.name == "submit" or name:
|
||||
self.id = name
|
||||
self.name = name
|
||||
19
CTFd/forms/language.py
Normal file
19
CTFd/forms/language.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from wtforms import RadioField
|
||||
|
||||
from CTFd.constants.languages import LANGUAGE_NAMES
|
||||
from CTFd.forms import BaseForm
|
||||
|
||||
|
||||
def LanguageForm(*args, **kwargs):
|
||||
from CTFd.utils.user import get_locale
|
||||
|
||||
class _LanguageForm(BaseForm):
|
||||
"""Language form for only switching langauge without rendering all profile settings"""
|
||||
|
||||
language = RadioField(
|
||||
"",
|
||||
choices=LANGUAGE_NAMES.items(),
|
||||
default=get_locale(),
|
||||
)
|
||||
|
||||
return _LanguageForm(*args, **kwargs)
|
||||
26
CTFd/forms/notifications.py
Normal file
26
CTFd/forms/notifications.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from wtforms import BooleanField, RadioField, StringField, TextAreaField
|
||||
from wtforms.validators import InputRequired
|
||||
|
||||
from CTFd.forms import BaseForm
|
||||
from CTFd.forms.fields import SubmitField
|
||||
|
||||
|
||||
class NotificationForm(BaseForm):
|
||||
title = StringField("Title", description="Notification title")
|
||||
content = TextAreaField(
|
||||
"Content",
|
||||
description="Notification contents. Can consist of HTML and/or Markdown.",
|
||||
)
|
||||
type = RadioField(
|
||||
"Notification Type",
|
||||
choices=[("toast", "Toast"), ("alert", "Alert"), ("background", "Background")],
|
||||
default="toast",
|
||||
description="What type of notification users receive",
|
||||
validators=[InputRequired()],
|
||||
)
|
||||
sound = BooleanField(
|
||||
"Play Sound",
|
||||
default=True,
|
||||
description="Play sound for users when they receive the notification",
|
||||
)
|
||||
submit = SubmitField("Submit")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user