mirror of
https://github.com/aljazceru/CTFd.git
synced 2025-12-18 06:24:23 +01:00
3.0.0a1 (#1523)
Alpha release of CTFd v3.
# 3.0.0a1 / 2020-07-01
**General**
- CTFd is now Python 3 only
- Render markdown with the CommonMark spec provided by `cmarkgfm`
- Render markdown stripped of any malicious JavaScript or HTML.
- This is a significant change from previous versions of CTFd where any HTML content from an admin was considered safe.
- Inject `Config`, `User`, `Team`, `Session`, and `Plugin` globals into Jinja
- User sessions no longer store any user-specific attributes.
- Sessions only store the user's ID, CSRF nonce, and an hmac of the user's password
- This allows for session invalidation on password changes
- The user facing side of CTFd now has user and team searching
- GeoIP support now available for converting IP addresses to guessed countries
**Admin Panel**
- Use EasyMDE as an improved description/text editor for Markdown enabled fields.
- Media Library button now integrated into EasyMDE enabled fields
- VueJS now used as the underlying implementation for the Media Library
- Fix setting theme color in Admin Panel
- Green outline border has been removed from the Admin Panel
**API**
- Significant overhauls in API documentation provided by Swagger UI and Swagger json
- Make almost all API endpoints provide filtering and searching capabilities
- Change `GET /api/v1/config/<config_key>` to return structured data according to ConfigSchema
**Themes**
- Themes now have access to the `Configs` global which provides wrapped access to `get_config`.
- For example, `{{ Configs.ctf_name }}` instead of `get_ctf_name()` or `get_config('ctf_name')`
- Themes must now specify a `challenge.html` which control how a challenge should look.
- The main library for charts has been changed from Plotly to Apache ECharts.
- Forms have been moved into wtforms for easier form rendering inside of Jinja.
- From Jinja you can access forms via the Forms global i.e. `{{ Forms }}`
- This allows theme developers to more easily re-use a form without having to copy-paste HTML.
- Themes can now provide a theme settings JSON blob which can be injected into the theme with `{{ Configs.theme_settings }}`
- Core theme now includes the challenge ID in location hash identifiers to always refer the right challenge despite duplicate names
**Plugins**
- Challenge plugins have changed in structure to better allow integration with themes and prevent obtrusive Javascript/XSS.
- Challenge rendering now uses `challenge.html` from the provided theme.
- Accessing the challenge view content is now provided by `/api/v1/challenges/<challenge_id>` in the `view` section. This allows for HTML to be properly sanitized and rendered by the server allowing CTFd to remove client side Jinja rendering.
- `challenge.html` now specifies what's required and what's rendered by the theme. This allows the challenge plugin to avoid having to deal with aspects of the challenge besides the description and input.
- A more complete migration guide will be provided when CTFd v3 leaves beta
- Display current attempt count in challenge view when max attempts is enabled
- `get_standings()`, `get_team_stanadings()`, `get_user_standings()` now has a fields keyword argument that allows for specificying additional fields that SQLAlchemy should return when building the response set.
- Useful for gathering additional data when building scoreboard pages
- Flags can now control the message that is shown to the user by raising `FlagException`
- Fix `override_template()` functionality
**Deployment**
- Enable SQLAlchemy's `pool_pre_ping` by default to reduce the likelihood of database connection issues
- Mailgun email settings are now deprecated. Admins should move to SMTP email settings instead.
- Postgres is now considered a second class citizen in CTFd. It is tested against but not a main database backend. If you use Postgres, you are entirely on your own with regards to supporting CTFd.
- Docker image now uses Debian instead of Alpine. See https://github.com/CTFd/CTFd/issues/1215 for rationale.
- `docker-compose.yml` now uses a non-root user to connect to MySQL/MariaDB
- `config.py` should no longer be editting for configuration, instead edit `config.ini` or the environment variables in `docker-compose.yml`
This commit is contained in:
@@ -4,11 +4,11 @@ import sys
|
||||
import weakref
|
||||
from distutils.version import StrictVersion
|
||||
|
||||
import jinja2
|
||||
from flask import Flask, Request
|
||||
from flask_migrate import upgrade
|
||||
from jinja2 import FileSystemLoader
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
from six.moves import input
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from werkzeug.utils import cached_property
|
||||
|
||||
@@ -26,12 +26,7 @@ from CTFd.utils.migrations import create_database, migrations, stamp_latest_revi
|
||||
from CTFd.utils.sessions import CachingSessionInterface
|
||||
from CTFd.utils.updates import update_check
|
||||
|
||||
# Hack to support Unicode in Python 2 properly
|
||||
if sys.version_info[0] < 3:
|
||||
reload(sys) # noqa: F821
|
||||
sys.setdefaultencoding("utf-8")
|
||||
|
||||
__version__ = "2.5.0"
|
||||
__version__ = "3.0.0a1"
|
||||
|
||||
|
||||
class CTFdRequest(Request):
|
||||
@@ -129,7 +124,7 @@ def confirm_upgrade():
|
||||
print("/*\\ CTFd has updated and must update the database! /*\\")
|
||||
print("/*\\ Please backup your database before proceeding! /*\\")
|
||||
print("/*\\ CTFd maintainers are not responsible for any data loss! /*\\")
|
||||
if input("Run database migrations (Y/N)").lower().strip() == "y":
|
||||
if input("Run database migrations (Y/N)").lower().strip() == "y": # nosec B322
|
||||
return True
|
||||
else:
|
||||
print("/*\\ Ignored database migrations... /*\\")
|
||||
@@ -148,10 +143,19 @@ def create_app(config="CTFd.config.Config"):
|
||||
with app.app_context():
|
||||
app.config.from_object(config)
|
||||
|
||||
theme_loader = ThemeLoader(
|
||||
app.theme_loader = ThemeLoader(
|
||||
os.path.join(app.root_path, "themes"), followlinks=True
|
||||
)
|
||||
app.jinja_loader = theme_loader
|
||||
# Weird nested solution for accessing plugin templates
|
||||
app.plugin_loader = jinja2.PrefixLoader(
|
||||
{
|
||||
"plugins": jinja2.FileSystemLoader(
|
||||
searchpath=os.path.join(app.root_path, "plugins"), followlinks=True
|
||||
)
|
||||
}
|
||||
)
|
||||
# Load from themes first but fallback to loading from the plugin folder
|
||||
app.jinja_loader = jinja2.ChoiceLoader([app.theme_loader, app.plugin_loader])
|
||||
|
||||
from CTFd.models import ( # noqa: F401
|
||||
db,
|
||||
@@ -215,16 +219,10 @@ def create_app(config="CTFd.config.Config"):
|
||||
if reverse_proxy:
|
||||
if type(reverse_proxy) is str and "," in reverse_proxy:
|
||||
proxyfix_args = [int(i) for i in reverse_proxy.split(",")]
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, None, *proxyfix_args)
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, *proxyfix_args)
|
||||
else:
|
||||
app.wsgi_app = ProxyFix(
|
||||
app.wsgi_app,
|
||||
num_proxies=None,
|
||||
x_for=1,
|
||||
x_proto=1,
|
||||
x_host=1,
|
||||
x_port=1,
|
||||
x_prefix=1,
|
||||
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1
|
||||
)
|
||||
|
||||
version = utils.get_config("ctf_version")
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import csv
|
||||
import datetime
|
||||
import os
|
||||
from io import BytesIO, StringIO
|
||||
|
||||
import six
|
||||
from flask import Blueprint, abort
|
||||
from flask import current_app as app
|
||||
from flask import (
|
||||
@@ -14,7 +14,18 @@ from flask import (
|
||||
url_for,
|
||||
)
|
||||
|
||||
from CTFd.cache import cache, clear_config, clear_standings, clear_pages
|
||||
admin = Blueprint("admin", __name__)
|
||||
|
||||
# isort:imports-firstparty
|
||||
from CTFd.admin import challenges # noqa: F401
|
||||
from CTFd.admin import notifications # noqa: F401
|
||||
from CTFd.admin import pages # noqa: F401
|
||||
from CTFd.admin import scoreboard # noqa: F401
|
||||
from CTFd.admin import statistics # noqa: F401
|
||||
from CTFd.admin import submissions # noqa: F401
|
||||
from CTFd.admin import teams # noqa: F401
|
||||
from CTFd.admin import users # noqa: F401
|
||||
from CTFd.cache import cache, clear_config, clear_pages, clear_standings
|
||||
from CTFd.models import (
|
||||
Awards,
|
||||
Challenges,
|
||||
@@ -40,17 +51,6 @@ from CTFd.utils.security.auth import logout_user
|
||||
from CTFd.utils.uploads import delete_file
|
||||
from CTFd.utils.user import is_admin
|
||||
|
||||
admin = Blueprint("admin", __name__)
|
||||
|
||||
from CTFd.admin import challenges # noqa: F401
|
||||
from CTFd.admin import notifications # noqa: F401
|
||||
from CTFd.admin import pages # noqa: F401
|
||||
from CTFd.admin import scoreboard # noqa: F401
|
||||
from CTFd.admin import statistics # noqa: F401
|
||||
from CTFd.admin import submissions # noqa: F401
|
||||
from CTFd.admin import teams # noqa: F401
|
||||
from CTFd.admin import users # noqa: F401
|
||||
|
||||
|
||||
@admin.route("/admin", methods=["GET"])
|
||||
def view():
|
||||
@@ -126,7 +126,7 @@ def export_csv():
|
||||
if model is None:
|
||||
abort(404)
|
||||
|
||||
temp = six.StringIO()
|
||||
temp = StringIO()
|
||||
writer = csv.writer(temp)
|
||||
|
||||
header = [column.name for column in model.__mapper__.columns]
|
||||
@@ -142,7 +142,7 @@ def export_csv():
|
||||
temp.seek(0)
|
||||
|
||||
# In Python 3 send_file requires bytes
|
||||
output = six.BytesIO()
|
||||
output = BytesIO()
|
||||
output.write(temp.getvalue().encode("utf-8"))
|
||||
output.seek(0)
|
||||
temp.close()
|
||||
@@ -163,17 +163,13 @@ def config():
|
||||
# Clear the config cache so that we don't get stale values
|
||||
clear_config()
|
||||
|
||||
database_tables = sorted(db.metadata.tables.keys())
|
||||
|
||||
configs = Configs.query.all()
|
||||
configs = dict([(c.key, get_config(c.key)) for c in configs])
|
||||
|
||||
themes = ctf_config.get_themes()
|
||||
themes.remove(get_config("ctf_theme"))
|
||||
|
||||
return render_template(
|
||||
"admin/config.html", database_tables=database_tables, themes=themes, **configs
|
||||
)
|
||||
return render_template("admin/config.html", themes=themes, **configs)
|
||||
|
||||
|
||||
@admin.route("/admin/reset", methods=["GET", "POST"])
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import os
|
||||
|
||||
import six
|
||||
from flask import current_app as app
|
||||
from flask import render_template, render_template_string, request, url_for
|
||||
from flask import render_template, request, url_for
|
||||
|
||||
from CTFd.admin import admin
|
||||
from CTFd.models import Challenges, Flags, Solves
|
||||
from CTFd.plugins.challenges import get_chal_class
|
||||
from CTFd.utils import binary_type
|
||||
from CTFd.utils.decorators import admins_only
|
||||
|
||||
|
||||
@@ -51,14 +46,9 @@ def challenges_detail(challenge_id):
|
||||
flags = Flags.query.filter_by(challenge_id=challenge.id).all()
|
||||
challenge_class = get_chal_class(challenge.type)
|
||||
|
||||
with open(
|
||||
os.path.join(app.root_path, challenge_class.templates["update"].lstrip("/")),
|
||||
"rb",
|
||||
) as update:
|
||||
tpl = update.read()
|
||||
if six.PY3 and isinstance(tpl, binary_type):
|
||||
tpl = tpl.decode("utf-8")
|
||||
update_j2 = render_template_string(tpl, challenge=challenge)
|
||||
update_j2 = render_template(
|
||||
challenge_class.templates["update"].lstrip("/"), challenge=challenge
|
||||
)
|
||||
|
||||
update_script = url_for(
|
||||
"views.static_html", route=challenge_class.scripts["update"].lstrip("/")
|
||||
|
||||
@@ -4,6 +4,7 @@ from CTFd.admin import admin
|
||||
from CTFd.models import Pages
|
||||
from CTFd.schemas.pages import PageSchema
|
||||
from CTFd.utils import markdown
|
||||
from CTFd.utils.config.pages import build_html
|
||||
from CTFd.utils.decorators import admins_only
|
||||
|
||||
|
||||
@@ -26,7 +27,7 @@ def pages_preview():
|
||||
data = request.form.to_dict()
|
||||
schema = PageSchema()
|
||||
page = schema.load(data)
|
||||
return render_template("page.html", content=markdown(page.data.content))
|
||||
return render_template("page.html", content=build_html(page.data.content))
|
||||
|
||||
|
||||
@admin.route("/admin/pages/<int:page_id>")
|
||||
|
||||
@@ -9,6 +9,11 @@ from CTFd.api.v1.flags import flags_namespace
|
||||
from CTFd.api.v1.hints import hints_namespace
|
||||
from CTFd.api.v1.notifications import notifications_namespace
|
||||
from CTFd.api.v1.pages import pages_namespace
|
||||
from CTFd.api.v1.schemas import (
|
||||
APIDetailedSuccessResponse,
|
||||
APISimpleErrorResponse,
|
||||
APISimpleSuccessResponse,
|
||||
)
|
||||
from CTFd.api.v1.scoreboard import scoreboard_namespace
|
||||
from CTFd.api.v1.statistics import statistics_namespace
|
||||
from CTFd.api.v1.submissions import submissions_namespace
|
||||
@@ -19,7 +24,13 @@ from CTFd.api.v1.unlocks import unlocks_namespace
|
||||
from CTFd.api.v1.users import users_namespace
|
||||
|
||||
api = Blueprint("api", __name__, url_prefix="/api/v1")
|
||||
CTFd_API_v1 = Api(api, version="v1", doc=current_app.config.get("SWAGGER_UI"))
|
||||
CTFd_API_v1 = Api(api, version="v1", doc=current_app.config.get("SWAGGER_UI_ENDPOINT"))
|
||||
|
||||
CTFd_API_v1.schema_model("APISimpleErrorResponse", APISimpleErrorResponse.schema())
|
||||
CTFd_API_v1.schema_model(
|
||||
"APIDetailedSuccessResponse", APIDetailedSuccessResponse.schema()
|
||||
)
|
||||
CTFd_API_v1.schema_model("APISimpleSuccessResponse", APISimpleSuccessResponse.schema())
|
||||
|
||||
CTFd_API_v1.add_namespace(challenges_namespace, "/challenges")
|
||||
CTFd_API_v1.add_namespace(tags_namespace, "/tags")
|
||||
|
||||
@@ -1,18 +1,103 @@
|
||||
from typing import List
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.models import build_model_filters
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||
from CTFd.cache import clear_standings
|
||||
from CTFd.utils.config import is_teams_mode
|
||||
from CTFd.models import Awards, db, Users
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import Awards, Users, db
|
||||
from CTFd.schemas.awards import AwardSchema
|
||||
from CTFd.utils.config import is_teams_mode
|
||||
from CTFd.utils.decorators import admins_only
|
||||
|
||||
awards_namespace = Namespace("awards", description="Endpoint to retrieve Awards")
|
||||
|
||||
AwardModel = sqlalchemy_to_pydantic(Awards)
|
||||
|
||||
|
||||
class AwardDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: AwardModel
|
||||
|
||||
|
||||
class AwardListSuccessResponse(APIListSuccessResponse):
|
||||
data: List[AwardModel]
|
||||
|
||||
|
||||
awards_namespace.schema_model(
|
||||
"AwardDetailedSuccessResponse", AwardDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
awards_namespace.schema_model(
|
||||
"AwardListSuccessResponse", AwardListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@awards_namespace.route("")
|
||||
class AwardList(Resource):
|
||||
@admins_only
|
||||
@awards_namespace.doc(
|
||||
description="Endpoint to list Award objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "AwardListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"user_id": (int, None),
|
||||
"team_id": (int, None),
|
||||
"type": (str, None),
|
||||
"value": (int, None),
|
||||
"category": (int, None),
|
||||
"icon": (int, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum(
|
||||
"AwardFields",
|
||||
{
|
||||
"name": "name",
|
||||
"description": "description",
|
||||
"category": "category",
|
||||
"icon": "icon",
|
||||
},
|
||||
),
|
||||
None,
|
||||
),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Awards, query=q, field=field)
|
||||
|
||||
awards = Awards.query.filter_by(**query_args).filter(*filters).all()
|
||||
schema = AwardSchema(many=True)
|
||||
response = schema.dump(awards)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@awards_namespace.doc(
|
||||
description="Endpoint to create an Award object",
|
||||
responses={
|
||||
200: ("Success", "AwardListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
|
||||
@@ -57,6 +142,16 @@ class AwardList(Resource):
|
||||
@awards_namespace.param("award_id", "An Award ID")
|
||||
class Award(Resource):
|
||||
@admins_only
|
||||
@awards_namespace.doc(
|
||||
description="Endpoint to get a specific Award object",
|
||||
responses={
|
||||
200: ("Success", "AwardDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, award_id):
|
||||
award = Awards.query.filter_by(id=award_id).first_or_404()
|
||||
response = AwardSchema().dump(award)
|
||||
@@ -66,6 +161,10 @@ class Award(Resource):
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@awards_namespace.doc(
|
||||
description="Endpoint to delete an Award object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self, award_id):
|
||||
award = Awards.query.filter_by(id=award_id).first_or_404()
|
||||
db.session.delete(award)
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
import datetime
|
||||
from typing import List
|
||||
|
||||
from flask import abort, request, url_for
|
||||
from flask import abort, render_template, request, url_for
|
||||
from flask_restx import Namespace, Resource
|
||||
from sqlalchemy.sql import and_
|
||||
|
||||
from CTFd.api.v1.helpers.models import build_model_filters
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||
from CTFd.cache import clear_standings
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import ChallengeFiles as ChallengeFilesModel
|
||||
from CTFd.models import Challenges, Fails, Flags, Hints, HintUnlocks, Solves, Tags, db
|
||||
from CTFd.models import (
|
||||
Challenges,
|
||||
Fails,
|
||||
Flags,
|
||||
Hints,
|
||||
HintUnlocks,
|
||||
Solves,
|
||||
Submissions,
|
||||
Tags,
|
||||
db,
|
||||
)
|
||||
from CTFd.plugins.challenges import CHALLENGE_CLASSES, get_chal_class
|
||||
from CTFd.schemas.flags import FlagSchema
|
||||
from CTFd.schemas.hints import HintSchema
|
||||
@@ -37,25 +53,92 @@ challenges_namespace = Namespace(
|
||||
"challenges", description="Endpoint to retrieve Challenges"
|
||||
)
|
||||
|
||||
ChallengeModel = sqlalchemy_to_pydantic(Challenges)
|
||||
TransientChallengeModel = sqlalchemy_to_pydantic(Challenges, exclude=["id"])
|
||||
|
||||
|
||||
class ChallengeDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: ChallengeModel
|
||||
|
||||
|
||||
class ChallengeListSuccessResponse(APIListSuccessResponse):
|
||||
data: List[ChallengeModel]
|
||||
|
||||
|
||||
challenges_namespace.schema_model(
|
||||
"ChallengeDetailedSuccessResponse", ChallengeDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
challenges_namespace.schema_model(
|
||||
"ChallengeListSuccessResponse", ChallengeListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@challenges_namespace.route("")
|
||||
class ChallengeList(Resource):
|
||||
@check_challenge_visibility
|
||||
@during_ctf_time_only
|
||||
@require_verified_emails
|
||||
def get(self):
|
||||
@challenges_namespace.doc(
|
||||
description="Endpoint to get Challenge objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "ChallengeListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"name": (str, None),
|
||||
"max_attempts": (int, None),
|
||||
"value": (int, None),
|
||||
"category": (str, None),
|
||||
"type": (str, None),
|
||||
"state": (str, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum(
|
||||
"ChallengeFields",
|
||||
{
|
||||
"name": "name",
|
||||
"description": "description",
|
||||
"category": "category",
|
||||
"type": "type",
|
||||
"state": "state",
|
||||
},
|
||||
),
|
||||
None,
|
||||
),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
# Build filtering queries
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Challenges, query=q, field=field)
|
||||
|
||||
# This can return None (unauth) if visibility is set to public
|
||||
user = get_current_user()
|
||||
|
||||
# Admins can request to see everything
|
||||
if is_admin() and request.args.get("view") == "admin":
|
||||
challenges = Challenges.query.order_by(Challenges.value).all()
|
||||
challenges = (
|
||||
Challenges.query.filter_by(**query_args)
|
||||
.filter(*filters)
|
||||
.order_by(Challenges.value)
|
||||
.all()
|
||||
)
|
||||
solve_ids = set([challenge.id for challenge in challenges])
|
||||
else:
|
||||
challenges = (
|
||||
Challenges.query.filter(
|
||||
and_(Challenges.state != "hidden", Challenges.state != "locked")
|
||||
)
|
||||
.filter_by(**query_args)
|
||||
.filter(*filters)
|
||||
.order_by(Challenges.value)
|
||||
.all()
|
||||
)
|
||||
@@ -122,6 +205,16 @@ class ChallengeList(Resource):
|
||||
return {"success": True, "data": response}
|
||||
|
||||
@admins_only
|
||||
@challenges_namespace.doc(
|
||||
description="Endpoint to create a Challenge object",
|
||||
responses={
|
||||
200: ("Success", "ChallengeDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def post(self):
|
||||
data = request.form or request.get_json()
|
||||
challenge_type = data["type"]
|
||||
@@ -144,16 +237,28 @@ class ChallengeTypes(Resource):
|
||||
"name": challenge_class.name,
|
||||
"templates": challenge_class.templates,
|
||||
"scripts": challenge_class.scripts,
|
||||
"create": render_template(
|
||||
challenge_class.templates["create"].lstrip("/")
|
||||
),
|
||||
}
|
||||
return {"success": True, "data": response}
|
||||
|
||||
|
||||
@challenges_namespace.route("/<challenge_id>")
|
||||
@challenges_namespace.param("challenge_id", "A Challenge ID")
|
||||
class Challenge(Resource):
|
||||
@check_challenge_visibility
|
||||
@during_ctf_time_only
|
||||
@require_verified_emails
|
||||
@challenges_namespace.doc(
|
||||
description="Endpoint to get a specific Challenge object",
|
||||
responses={
|
||||
200: ("Success", "ChallengeDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, challenge_id):
|
||||
if is_admin():
|
||||
chal = Challenges.query.filter(Challenges.id == challenge_id).first_or_404()
|
||||
@@ -270,14 +375,44 @@ class Challenge(Resource):
|
||||
else:
|
||||
response["solves"] = None
|
||||
|
||||
if authed():
|
||||
# Get current attempts for the user
|
||||
attempts = Submissions.query.filter_by(
|
||||
account_id=user.account_id, challenge_id=challenge_id
|
||||
).count()
|
||||
else:
|
||||
attempts = 0
|
||||
|
||||
response["attempts"] = attempts
|
||||
response["files"] = files
|
||||
response["tags"] = tags
|
||||
response["hints"] = hints
|
||||
|
||||
response["view"] = render_template(
|
||||
chal_class.templates["view"].lstrip("/"),
|
||||
solves=solves,
|
||||
files=files,
|
||||
tags=tags,
|
||||
hints=[Hints(**h) for h in hints],
|
||||
max_attempts=chal.max_attempts,
|
||||
attempts=attempts,
|
||||
challenge=chal,
|
||||
)
|
||||
|
||||
db.session.close()
|
||||
return {"success": True, "data": response}
|
||||
|
||||
@admins_only
|
||||
@challenges_namespace.doc(
|
||||
description="Endpoint to edit a specific Challenge object",
|
||||
responses={
|
||||
200: ("Success", "ChallengeDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def patch(self, challenge_id):
|
||||
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
|
||||
challenge_class = get_chal_class(challenge.type)
|
||||
@@ -286,6 +421,10 @@ class Challenge(Resource):
|
||||
return {"success": True, "data": response}
|
||||
|
||||
@admins_only
|
||||
@challenges_namespace.doc(
|
||||
description="Endpoint to delete a specific Challenge object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self, challenge_id):
|
||||
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
|
||||
chal_class = get_chal_class(challenge.type)
|
||||
@@ -496,7 +635,6 @@ class ChallengeAttempt(Resource):
|
||||
|
||||
|
||||
@challenges_namespace.route("/<challenge_id>/solves")
|
||||
@challenges_namespace.param("id", "A Challenge ID")
|
||||
class ChallengeSolves(Resource):
|
||||
@check_challenge_visibility
|
||||
@check_score_visibility
|
||||
@@ -544,7 +682,6 @@ class ChallengeSolves(Resource):
|
||||
|
||||
|
||||
@challenges_namespace.route("/<challenge_id>/files")
|
||||
@challenges_namespace.param("id", "A Challenge ID")
|
||||
class ChallengeFiles(Resource):
|
||||
@admins_only
|
||||
def get(self, challenge_id):
|
||||
@@ -560,7 +697,6 @@ class ChallengeFiles(Resource):
|
||||
|
||||
|
||||
@challenges_namespace.route("/<challenge_id>/tags")
|
||||
@challenges_namespace.param("id", "A Challenge ID")
|
||||
class ChallengeTags(Resource):
|
||||
@admins_only
|
||||
def get(self, challenge_id):
|
||||
@@ -576,7 +712,6 @@ class ChallengeTags(Resource):
|
||||
|
||||
|
||||
@challenges_namespace.route("/<challenge_id>/hints")
|
||||
@challenges_namespace.param("id", "A Challenge ID")
|
||||
class ChallengeHints(Resource):
|
||||
@admins_only
|
||||
def get(self, challenge_id):
|
||||
@@ -591,7 +726,6 @@ class ChallengeHints(Resource):
|
||||
|
||||
|
||||
@challenges_namespace.route("/<challenge_id>/flags")
|
||||
@challenges_namespace.param("id", "A Challenge ID")
|
||||
class ChallengeFlags(Resource):
|
||||
@admins_only
|
||||
def get(self, challenge_id):
|
||||
|
||||
@@ -1,20 +1,69 @@
|
||||
from typing import List
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.models import build_model_filters
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||
from CTFd.cache import clear_config, clear_standings
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import Configs, db
|
||||
from CTFd.schemas.config import ConfigSchema
|
||||
from CTFd.utils import get_config, set_config
|
||||
from CTFd.utils import set_config
|
||||
from CTFd.utils.decorators import admins_only
|
||||
|
||||
configs_namespace = Namespace("configs", description="Endpoint to retrieve Configs")
|
||||
|
||||
ConfigModel = sqlalchemy_to_pydantic(Configs)
|
||||
|
||||
|
||||
class ConfigDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: ConfigModel
|
||||
|
||||
|
||||
class ConfigListSuccessResponse(APIListSuccessResponse):
|
||||
data: List[ConfigModel]
|
||||
|
||||
|
||||
configs_namespace.schema_model(
|
||||
"ConfigDetailedSuccessResponse", ConfigDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
configs_namespace.schema_model(
|
||||
"ConfigListSuccessResponse", ConfigListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@configs_namespace.route("")
|
||||
class ConfigList(Resource):
|
||||
@admins_only
|
||||
def get(self):
|
||||
configs = Configs.query.all()
|
||||
@configs_namespace.doc(
|
||||
description="Endpoint to get Config objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "ConfigListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"key": (str, None),
|
||||
"value": (str, None),
|
||||
"q": (str, None),
|
||||
"field": (RawEnum("ConfigFields", {"key": "key", "value": "value"}), None),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Configs, query=q, field=field)
|
||||
|
||||
configs = Configs.query.filter_by(**query_args).filter(*filters).all()
|
||||
schema = ConfigSchema(many=True)
|
||||
response = schema.dump(configs)
|
||||
if response.errors:
|
||||
@@ -23,6 +72,16 @@ class ConfigList(Resource):
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@configs_namespace.doc(
|
||||
description="Endpoint to get create a Config object",
|
||||
responses={
|
||||
200: ("Success", "ConfigDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
schema = ConfigSchema()
|
||||
@@ -43,6 +102,10 @@ class ConfigList(Resource):
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@configs_namespace.doc(
|
||||
description="Endpoint to get patch Config objects in bulk",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def patch(self):
|
||||
req = request.get_json()
|
||||
|
||||
@@ -58,11 +121,33 @@ class ConfigList(Resource):
|
||||
@configs_namespace.route("/<config_key>")
|
||||
class Config(Resource):
|
||||
@admins_only
|
||||
@configs_namespace.doc(
|
||||
description="Endpoint to get a specific Config object",
|
||||
responses={
|
||||
200: ("Success", "ConfigDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, config_key):
|
||||
|
||||
return {"success": True, "data": get_config(config_key)}
|
||||
config = Configs.query.filter_by(key=config_key).first_or_404()
|
||||
schema = ConfigSchema()
|
||||
response = schema.dump(config)
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@configs_namespace.doc(
|
||||
description="Endpoint to edit a specific Config object",
|
||||
responses={
|
||||
200: ("Success", "ConfigDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def patch(self, config_key):
|
||||
config = Configs.query.filter_by(key=config_key).first()
|
||||
data = request.get_json()
|
||||
@@ -89,6 +174,10 @@ class Config(Resource):
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@configs_namespace.doc(
|
||||
description="Endpoint to delete a Config object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self, config_key):
|
||||
config = Configs.query.filter_by(key=config_key).first_or_404()
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
from typing import List
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.models import build_model_filters
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import Files, db
|
||||
from CTFd.schemas.files import FileSchema
|
||||
from CTFd.utils import uploads
|
||||
@@ -8,13 +15,57 @@ from CTFd.utils.decorators import admins_only
|
||||
|
||||
files_namespace = Namespace("files", description="Endpoint to retrieve Files")
|
||||
|
||||
FileModel = sqlalchemy_to_pydantic(Files)
|
||||
|
||||
|
||||
class FileDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: FileModel
|
||||
|
||||
|
||||
class FileListSuccessResponse(APIListSuccessResponse):
|
||||
data: List[FileModel]
|
||||
|
||||
|
||||
files_namespace.schema_model(
|
||||
"FileDetailedSuccessResponse", FileDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
files_namespace.schema_model(
|
||||
"FileListSuccessResponse", FileListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@files_namespace.route("")
|
||||
class FilesList(Resource):
|
||||
@admins_only
|
||||
def get(self):
|
||||
file_type = request.args.get("type")
|
||||
files = Files.query.filter_by(type=file_type).all()
|
||||
@files_namespace.doc(
|
||||
description="Endpoint to get file objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "FileListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"type": (str, None),
|
||||
"location": (str, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum("FileFields", {"type": "type", "location": "location"}),
|
||||
None,
|
||||
),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Files, query=q, field=field)
|
||||
|
||||
files = Files.query.filter_by(**query_args).filter(*filters).all()
|
||||
schema = FileSchema(many=True)
|
||||
response = schema.dump(files)
|
||||
|
||||
@@ -24,6 +75,16 @@ class FilesList(Resource):
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@files_namespace.doc(
|
||||
description="Endpoint to get file objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "FileDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def post(self):
|
||||
files = request.files.getlist("file")
|
||||
# challenge_id
|
||||
@@ -47,6 +108,16 @@ class FilesList(Resource):
|
||||
@files_namespace.route("/<file_id>")
|
||||
class FilesDetail(Resource):
|
||||
@admins_only
|
||||
@files_namespace.doc(
|
||||
description="Endpoint to get a specific file object",
|
||||
responses={
|
||||
200: ("Success", "FileDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, file_id):
|
||||
f = Files.query.filter_by(id=file_id).first_or_404()
|
||||
schema = FileSchema()
|
||||
@@ -58,6 +129,10 @@ class FilesDetail(Resource):
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@files_namespace.doc(
|
||||
description="Endpoint to delete a file object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self, file_id):
|
||||
f = Files.query.filter_by(id=file_id).first_or_404()
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
from typing import List
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.models import build_model_filters
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import Flags, db
|
||||
from CTFd.plugins.flags import FLAG_CLASSES, get_flag_class
|
||||
from CTFd.schemas.flags import FlagSchema
|
||||
@@ -8,12 +15,61 @@ from CTFd.utils.decorators import admins_only
|
||||
|
||||
flags_namespace = Namespace("flags", description="Endpoint to retrieve Flags")
|
||||
|
||||
FlagModel = sqlalchemy_to_pydantic(Flags)
|
||||
|
||||
|
||||
class FlagDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: FlagModel
|
||||
|
||||
|
||||
class FlagListSuccessResponse(APIListSuccessResponse):
|
||||
data: List[FlagModel]
|
||||
|
||||
|
||||
flags_namespace.schema_model(
|
||||
"FlagDetailedSuccessResponse", FlagDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
flags_namespace.schema_model(
|
||||
"FlagListSuccessResponse", FlagListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@flags_namespace.route("")
|
||||
class FlagList(Resource):
|
||||
@admins_only
|
||||
def get(self):
|
||||
flags = Flags.query.all()
|
||||
@flags_namespace.doc(
|
||||
description="Endpoint to list Flag objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "FlagListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"challenge_id": (int, None),
|
||||
"type": (str, None),
|
||||
"content": (str, None),
|
||||
"data": (str, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum(
|
||||
"FlagFields", {"type": "type", "content": "content", "data": "data"}
|
||||
),
|
||||
None,
|
||||
),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Flags, query=q, field=field)
|
||||
|
||||
flags = Flags.query.filter_by(**query_args).filter(*filters).all()
|
||||
schema = FlagSchema(many=True)
|
||||
response = schema.dump(flags)
|
||||
if response.errors:
|
||||
@@ -22,6 +78,16 @@ class FlagList(Resource):
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@flags_namespace.doc(
|
||||
description="Endpoint to create a Flag object",
|
||||
responses={
|
||||
200: ("Success", "FlagDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
schema = FlagSchema()
|
||||
@@ -62,6 +128,16 @@ class FlagTypes(Resource):
|
||||
@flags_namespace.route("/<flag_id>")
|
||||
class Flag(Resource):
|
||||
@admins_only
|
||||
@flags_namespace.doc(
|
||||
description="Endpoint to get a specific Flag object",
|
||||
responses={
|
||||
200: ("Success", "FlagDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, flag_id):
|
||||
flag = Flags.query.filter_by(id=flag_id).first_or_404()
|
||||
schema = FlagSchema()
|
||||
@@ -75,6 +151,10 @@ class Flag(Resource):
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@flags_namespace.doc(
|
||||
description="Endpoint to delete a specific Flag object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self, flag_id):
|
||||
flag = Flags.query.filter_by(id=flag_id).first_or_404()
|
||||
|
||||
@@ -85,6 +165,16 @@ class Flag(Resource):
|
||||
return {"success": True}
|
||||
|
||||
@admins_only
|
||||
@flags_namespace.doc(
|
||||
description="Endpoint to edit a specific Flag object",
|
||||
responses={
|
||||
200: ("Success", "FlagDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def patch(self, flag_id):
|
||||
flag = Flags.query.filter_by(id=flag_id).first_or_404()
|
||||
schema = FlagSchema()
|
||||
|
||||
0
CTFd/api/v1/helpers/__init__.py
Normal file
0
CTFd/api/v1/helpers/__init__.py
Normal file
7
CTFd/api/v1/helpers/models.py
Normal file
7
CTFd/api/v1/helpers/models.py
Normal file
@@ -0,0 +1,7 @@
|
||||
def build_model_filters(model, query, field):
|
||||
filters = []
|
||||
if query:
|
||||
# The field exists as an exposed column
|
||||
if model.__mapper__.has_property(field):
|
||||
filters.append(getattr(model, field).like("%{}%".format(query)))
|
||||
return filters
|
||||
49
CTFd/api/v1/helpers/request.py
Normal file
49
CTFd/api/v1/helpers/request.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from functools import wraps
|
||||
|
||||
from flask import request
|
||||
from pydantic import create_model
|
||||
|
||||
ARG_LOCATIONS = {
|
||||
"query": lambda: request.args,
|
||||
"json": lambda: request.get_json(),
|
||||
"form": lambda: request.form,
|
||||
"headers": lambda: request.headers,
|
||||
"cookies": lambda: request.cookies,
|
||||
}
|
||||
|
||||
|
||||
def validate_args(spec, location):
|
||||
"""
|
||||
A rough implementation of webargs using pydantic schemas. You can pass a
|
||||
pydantic schema as spec or create it on the fly as follows:
|
||||
|
||||
@validate_args({"name": (str, None), "id": (int, None)}, location="query")
|
||||
"""
|
||||
if isinstance(spec, dict):
|
||||
spec = create_model("", **spec)
|
||||
|
||||
schema = spec.schema()
|
||||
props = schema.get("properties", {})
|
||||
required = schema.get("required", [])
|
||||
|
||||
for k in props:
|
||||
if k in required:
|
||||
props[k]["required"] = True
|
||||
props[k]["in"] = location
|
||||
|
||||
def decorator(func):
|
||||
# Inject parameters information into the Flask-Restx apidoc attribute.
|
||||
# Not really a good solution. See https://github.com/CTFd/CTFd/issues/1504
|
||||
apidoc = getattr(func, "__apidoc__", {"params": {}})
|
||||
apidoc["params"].update(props)
|
||||
func.__apidoc__ = apidoc
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
data = ARG_LOCATIONS[location]()
|
||||
loaded = spec(**data).dict(exclude_unset=True)
|
||||
return func(*args, loaded, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
31
CTFd/api/v1/helpers/schemas.py
Normal file
31
CTFd/api/v1/helpers/schemas.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from typing import Container, Type
|
||||
|
||||
from pydantic import BaseModel, create_model
|
||||
from sqlalchemy.inspection import inspect
|
||||
from sqlalchemy.orm.properties import ColumnProperty
|
||||
|
||||
|
||||
def sqlalchemy_to_pydantic(
|
||||
db_model: Type, *, exclude: Container[str] = []
|
||||
) -> Type[BaseModel]:
|
||||
"""
|
||||
Mostly copied from https://github.com/tiangolo/pydantic-sqlalchemy
|
||||
"""
|
||||
mapper = inspect(db_model)
|
||||
fields = {}
|
||||
for attr in mapper.attrs:
|
||||
if isinstance(attr, ColumnProperty):
|
||||
if attr.columns:
|
||||
column = attr.columns[0]
|
||||
python_type = column.type.python_type
|
||||
name = attr.key
|
||||
if name in exclude:
|
||||
continue
|
||||
default = None
|
||||
if column.default is None and not column.nullable:
|
||||
default = ...
|
||||
fields[name] = (python_type, default)
|
||||
pydantic_model = create_model(
|
||||
db_model.__name__, **fields # type: ignore
|
||||
)
|
||||
return pydantic_model
|
||||
@@ -1,6 +1,13 @@
|
||||
from typing import List
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.models import build_model_filters
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import Hints, HintUnlocks, db
|
||||
from CTFd.schemas.hints import HintSchema
|
||||
from CTFd.utils.decorators import admins_only, authed_only, during_ctf_time_only
|
||||
@@ -8,12 +15,59 @@ from CTFd.utils.user import get_current_user, is_admin
|
||||
|
||||
hints_namespace = Namespace("hints", description="Endpoint to retrieve Hints")
|
||||
|
||||
HintModel = sqlalchemy_to_pydantic(Hints)
|
||||
|
||||
|
||||
class HintDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: HintModel
|
||||
|
||||
|
||||
class HintListSuccessResponse(APIListSuccessResponse):
|
||||
data: List[HintModel]
|
||||
|
||||
|
||||
hints_namespace.schema_model(
|
||||
"HintDetailedSuccessResponse", HintDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
hints_namespace.schema_model(
|
||||
"HintListSuccessResponse", HintListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@hints_namespace.route("")
|
||||
class HintList(Resource):
|
||||
@admins_only
|
||||
def get(self):
|
||||
hints = Hints.query.all()
|
||||
@hints_namespace.doc(
|
||||
description="Endpoint to list Hint objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "HintListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"type": (str, None),
|
||||
"challenge_id": (int, None),
|
||||
"content": (str, None),
|
||||
"cost": (int, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum("HintFields", {"type": "type", "content": "content"}),
|
||||
None,
|
||||
),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Hints, query=q, field=field)
|
||||
|
||||
hints = Hints.query.filter_by(**query_args).filter(*filters).all()
|
||||
response = HintSchema(many=True).dump(hints)
|
||||
|
||||
if response.errors:
|
||||
@@ -22,6 +76,16 @@ class HintList(Resource):
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@hints_namespace.doc(
|
||||
description="Endpoint to create a Hint object",
|
||||
responses={
|
||||
200: ("Success", "HintDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
schema = HintSchema("admin")
|
||||
@@ -42,6 +106,16 @@ class HintList(Resource):
|
||||
class Hint(Resource):
|
||||
@during_ctf_time_only
|
||||
@authed_only
|
||||
@hints_namespace.doc(
|
||||
description="Endpoint to get a specific Hint object",
|
||||
responses={
|
||||
200: ("Success", "HintDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, hint_id):
|
||||
user = get_current_user()
|
||||
hint = Hints.query.filter_by(id=hint_id).first_or_404()
|
||||
@@ -67,6 +141,16 @@ class Hint(Resource):
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@hints_namespace.doc(
|
||||
description="Endpoint to edit a specific Hint object",
|
||||
responses={
|
||||
200: ("Success", "HintDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def patch(self, hint_id):
|
||||
hint = Hints.query.filter_by(id=hint_id).first_or_404()
|
||||
req = request.get_json()
|
||||
@@ -85,6 +169,10 @@ class Hint(Resource):
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@hints_namespace.doc(
|
||||
description="Endpoint to delete a specific Tag object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self, hint_id):
|
||||
hint = Hints.query.filter_by(id=hint_id).first_or_404()
|
||||
db.session.delete(hint)
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
from typing import List
|
||||
|
||||
from flask import current_app, request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.models import build_model_filters
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import Notifications, db
|
||||
from CTFd.schemas.notifications import NotificationSchema
|
||||
from CTFd.utils.decorators import admins_only
|
||||
@@ -9,11 +16,61 @@ notifications_namespace = Namespace(
|
||||
"notifications", description="Endpoint to retrieve Notifications"
|
||||
)
|
||||
|
||||
NotificationModel = sqlalchemy_to_pydantic(Notifications)
|
||||
TransientNotificationModel = sqlalchemy_to_pydantic(Notifications, exclude=["id"])
|
||||
|
||||
|
||||
class NotificationDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: NotificationModel
|
||||
|
||||
|
||||
class NotificationListSuccessResponse(APIListSuccessResponse):
|
||||
data: List[NotificationModel]
|
||||
|
||||
|
||||
notifications_namespace.schema_model(
|
||||
"NotificationDetailedSuccessResponse", NotificationDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
notifications_namespace.schema_model(
|
||||
"NotificationListSuccessResponse", NotificationListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@notifications_namespace.route("")
|
||||
class NotificantionList(Resource):
|
||||
def get(self):
|
||||
notifications = Notifications.query.all()
|
||||
@notifications_namespace.doc(
|
||||
description="Endpoint to get notification objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "NotificationListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"title": (str, None),
|
||||
"content": (str, None),
|
||||
"user_id": (int, None),
|
||||
"team_id": (int, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum("NotificationFields", {"title": "title", "content": "content"}),
|
||||
None,
|
||||
),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Notifications, query=q, field=field)
|
||||
|
||||
notifications = (
|
||||
Notifications.query.filter_by(**query_args).filter(*filters).all()
|
||||
)
|
||||
schema = NotificationSchema(many=True)
|
||||
result = schema.dump(notifications)
|
||||
if result.errors:
|
||||
@@ -21,6 +78,16 @@ class NotificantionList(Resource):
|
||||
return {"success": True, "data": result.data}
|
||||
|
||||
@admins_only
|
||||
@notifications_namespace.doc(
|
||||
description="Endpoint to create a notification object",
|
||||
responses={
|
||||
200: ("Success", "NotificationDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
|
||||
@@ -49,6 +116,16 @@ class NotificantionList(Resource):
|
||||
@notifications_namespace.route("/<notification_id>")
|
||||
@notifications_namespace.param("notification_id", "A Notification ID")
|
||||
class Notification(Resource):
|
||||
@notifications_namespace.doc(
|
||||
description="Endpoint to get a specific notification object",
|
||||
responses={
|
||||
200: ("Success", "NotificationDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, notification_id):
|
||||
notif = Notifications.query.filter_by(id=notification_id).first_or_404()
|
||||
schema = NotificationSchema()
|
||||
@@ -59,6 +136,10 @@ class Notification(Resource):
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@notifications_namespace.doc(
|
||||
description="Endpoint to delete a notification object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self, notification_id):
|
||||
notif = Notifications.query.filter_by(id=notification_id).first_or_404()
|
||||
db.session.delete(notif)
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
from typing import List
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.models import build_model_filters
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||
from CTFd.cache import clear_pages
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import Pages, db
|
||||
from CTFd.schemas.pages import PageSchema
|
||||
from CTFd.utils.decorators import admins_only
|
||||
@@ -9,11 +16,68 @@ from CTFd.utils.decorators import admins_only
|
||||
pages_namespace = Namespace("pages", description="Endpoint to retrieve Pages")
|
||||
|
||||
|
||||
PageModel = sqlalchemy_to_pydantic(Pages)
|
||||
TransientPageModel = sqlalchemy_to_pydantic(Pages, exclude=["id"])
|
||||
|
||||
|
||||
class PageDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: PageModel
|
||||
|
||||
|
||||
class PageListSuccessResponse(APIListSuccessResponse):
|
||||
data: List[PageModel]
|
||||
|
||||
|
||||
pages_namespace.schema_model(
|
||||
"PageDetailedSuccessResponse", PageDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
pages_namespace.schema_model(
|
||||
"PageListSuccessResponse", PageListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@pages_namespace.route("")
|
||||
@pages_namespace.doc(
|
||||
responses={200: "Success", 400: "An error occured processing your data"}
|
||||
)
|
||||
class PageList(Resource):
|
||||
@admins_only
|
||||
def get(self):
|
||||
pages = Pages.query.all()
|
||||
@pages_namespace.doc(
|
||||
description="Endpoint to get page objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "PageListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"id": (int, None),
|
||||
"title": (str, None),
|
||||
"route": (str, None),
|
||||
"draft": (bool, None),
|
||||
"hidden": (bool, None),
|
||||
"auth_required": (bool, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum(
|
||||
"PageFields",
|
||||
{"title": "title", "route": "route", "content": "content"},
|
||||
),
|
||||
None,
|
||||
),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Pages, query=q, field=field)
|
||||
|
||||
pages = Pages.query.filter_by(**query_args).filter(*filters).all()
|
||||
schema = PageSchema(exclude=["content"], many=True)
|
||||
response = schema.dump(pages)
|
||||
if response.errors:
|
||||
@@ -22,8 +86,19 @@ class PageList(Resource):
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
@pages_namespace.doc(
|
||||
description="Endpoint to create a page object",
|
||||
responses={
|
||||
200: ("Success", "PageDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(TransientPageModel, location="json")
|
||||
def post(self, json_args):
|
||||
req = json_args
|
||||
schema = PageSchema()
|
||||
response = schema.load(req)
|
||||
|
||||
@@ -42,8 +117,19 @@ class PageList(Resource):
|
||||
|
||||
|
||||
@pages_namespace.route("/<page_id>")
|
||||
@pages_namespace.doc(
|
||||
params={"page_id": "ID of a page object"},
|
||||
responses={
|
||||
200: ("Success", "PageDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
class PageDetail(Resource):
|
||||
@admins_only
|
||||
@pages_namespace.doc(description="Endpoint to read a page object")
|
||||
def get(self, page_id):
|
||||
page = Pages.query.filter_by(id=page_id).first_or_404()
|
||||
schema = PageSchema()
|
||||
@@ -55,6 +141,7 @@ class PageDetail(Resource):
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@pages_namespace.doc(description="Endpoint to edit a page object")
|
||||
def patch(self, page_id):
|
||||
page = Pages.query.filter_by(id=page_id).first_or_404()
|
||||
req = request.get_json()
|
||||
@@ -75,6 +162,10 @@ class PageDetail(Resource):
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@pages_namespace.doc(
|
||||
description="Endpoint to delete a page object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self, page_id):
|
||||
page = Pages.query.filter_by(id=page_id).first_or_404()
|
||||
db.session.delete(page)
|
||||
|
||||
105
CTFd/api/v1/schemas/__init__.py
Normal file
105
CTFd/api/v1/schemas/__init__.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class APISimpleSuccessResponse(BaseModel):
|
||||
success: bool = True
|
||||
|
||||
|
||||
class APIDetailedSuccessResponse(APISimpleSuccessResponse):
|
||||
data: Optional[Any]
|
||||
|
||||
@classmethod
|
||||
def apidoc(cls):
|
||||
"""
|
||||
Helper to inline references from the generated schema
|
||||
"""
|
||||
schema = cls.schema()
|
||||
|
||||
try:
|
||||
key = schema["properties"]["data"]["$ref"]
|
||||
ref = key.split("/").pop()
|
||||
definition = schema["definitions"][ref]
|
||||
schema["properties"]["data"] = definition
|
||||
del schema["definitions"][ref]
|
||||
if bool(schema["definitions"]) is False:
|
||||
del schema["definitions"]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
class APIListSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: Optional[List[Any]]
|
||||
|
||||
@classmethod
|
||||
def apidoc(cls):
|
||||
"""
|
||||
Helper to inline references from the generated schema
|
||||
"""
|
||||
schema = cls.schema()
|
||||
|
||||
try:
|
||||
key = schema["properties"]["data"]["items"]["$ref"]
|
||||
ref = key.split("/").pop()
|
||||
definition = schema["definitions"][ref]
|
||||
schema["properties"]["data"]["items"] = definition
|
||||
del schema["definitions"][ref]
|
||||
if bool(schema["definitions"]) is False:
|
||||
del schema["definitions"]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
class PaginatedAPIListSuccessResponse(APIListSuccessResponse):
|
||||
meta: Dict[str, Any]
|
||||
|
||||
@classmethod
|
||||
def apidoc(cls):
|
||||
"""
|
||||
Helper to inline references from the generated schema
|
||||
"""
|
||||
schema = cls.schema()
|
||||
|
||||
schema["properties"]["meta"] = {
|
||||
"title": "Meta",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pagination": {
|
||||
"title": "Pagination",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page": {"title": "Page", "type": "integer"},
|
||||
"next": {"title": "Next", "type": "integer"},
|
||||
"prev": {"title": "Prev", "type": "integer"},
|
||||
"pages": {"title": "Pages", "type": "integer"},
|
||||
"per_page": {"title": "Per Page", "type": "integer"},
|
||||
"total": {"title": "Total", "type": "integer"},
|
||||
},
|
||||
"required": ["page", "next", "prev", "pages", "per_page", "total"],
|
||||
}
|
||||
},
|
||||
"required": ["pagination"],
|
||||
}
|
||||
|
||||
try:
|
||||
key = schema["properties"]["data"]["items"]["$ref"]
|
||||
ref = key.split("/").pop()
|
||||
definition = schema["definitions"][ref]
|
||||
schema["properties"]["data"]["items"] = definition
|
||||
del schema["definitions"][ref]
|
||||
if bool(schema["definitions"]) is False:
|
||||
del schema["definitions"]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
class APISimpleErrorResponse(BaseModel):
|
||||
success: bool = False
|
||||
errors: Optional[List[str]]
|
||||
@@ -1,7 +1,6 @@
|
||||
from collections import defaultdict
|
||||
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from CTFd.cache import cache, make_cache_key
|
||||
|
||||
@@ -4,8 +4,9 @@ statistics_namespace = Namespace(
|
||||
"statistics", description="Endpoint to retrieve Statistics"
|
||||
)
|
||||
|
||||
# isort:imports-firstparty
|
||||
from CTFd.api.v1.statistics import challenges # noqa: F401
|
||||
from CTFd.api.v1.statistics import scores # noqa: F401
|
||||
from CTFd.api.v1.statistics import submissions # noqa: F401
|
||||
from CTFd.api.v1.statistics import teams # noqa: F401
|
||||
from CTFd.api.v1.statistics import users # noqa: F401
|
||||
from CTFd.api.v1.statistics import scores # noqa: F401
|
||||
|
||||
@@ -3,7 +3,7 @@ from collections import defaultdict
|
||||
from flask_restx import Resource
|
||||
|
||||
from CTFd.api.v1.statistics import statistics_namespace
|
||||
from CTFd.models import db, Challenges
|
||||
from CTFd.models import Challenges, db
|
||||
from CTFd.utils.decorators import admins_only
|
||||
from CTFd.utils.scores import get_standings
|
||||
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
from flask import request
|
||||
from typing import List
|
||||
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.models import build_model_filters
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import (
|
||||
APIDetailedSuccessResponse,
|
||||
PaginatedAPIListSuccessResponse,
|
||||
)
|
||||
from CTFd.cache import clear_standings
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import Submissions, db
|
||||
from CTFd.schemas.submissions import SubmissionSchema
|
||||
from CTFd.utils.decorators import admins_only
|
||||
@@ -10,28 +19,114 @@ submissions_namespace = Namespace(
|
||||
"submissions", description="Endpoint to retrieve Submission"
|
||||
)
|
||||
|
||||
SubmissionModel = sqlalchemy_to_pydantic(Submissions)
|
||||
TransientSubmissionModel = sqlalchemy_to_pydantic(Submissions, exclude=["id"])
|
||||
|
||||
|
||||
class SubmissionDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: SubmissionModel
|
||||
|
||||
|
||||
class SubmissionListSuccessResponse(PaginatedAPIListSuccessResponse):
|
||||
data: List[SubmissionModel]
|
||||
|
||||
|
||||
submissions_namespace.schema_model(
|
||||
"SubmissionDetailedSuccessResponse", SubmissionDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
submissions_namespace.schema_model(
|
||||
"SubmissionListSuccessResponse", SubmissionListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@submissions_namespace.route("")
|
||||
class SubmissionsList(Resource):
|
||||
@admins_only
|
||||
def get(self):
|
||||
args = request.args.to_dict()
|
||||
schema = SubmissionSchema(many=True)
|
||||
if args:
|
||||
submissions = Submissions.query.filter_by(**args).all()
|
||||
else:
|
||||
submissions = Submissions.query.all()
|
||||
@submissions_namespace.doc(
|
||||
description="Endpoint to get submission objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "SubmissionListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"challenge_id": (int, None),
|
||||
"user_id": (int, None),
|
||||
"team_id": (int, None),
|
||||
"ip": (str, None),
|
||||
"provided": (str, None),
|
||||
"type": (str, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum(
|
||||
"SubmissionFields",
|
||||
{
|
||||
"challenge_id": "challenge_id",
|
||||
"user_id": "user_id",
|
||||
"team_id": "team_id",
|
||||
"ip": "ip",
|
||||
"provided": "provided",
|
||||
"type": "type",
|
||||
},
|
||||
),
|
||||
None,
|
||||
),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Submissions, query=q, field=field)
|
||||
|
||||
response = schema.dump(submissions)
|
||||
args = query_args
|
||||
schema = SubmissionSchema(many=True)
|
||||
|
||||
submissions = (
|
||||
Submissions.query.filter_by(**args)
|
||||
.filter(*filters)
|
||||
.paginate(max_per_page=100)
|
||||
)
|
||||
|
||||
response = schema.dump(submissions.items)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
return {
|
||||
"meta": {
|
||||
"pagination": {
|
||||
"page": submissions.page,
|
||||
"next": submissions.next_num,
|
||||
"prev": submissions.prev_num,
|
||||
"pages": submissions.pages,
|
||||
"per_page": submissions.per_page,
|
||||
"total": submissions.total,
|
||||
}
|
||||
},
|
||||
"success": True,
|
||||
"data": response.data,
|
||||
}
|
||||
|
||||
@admins_only
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
@submissions_namespace.doc(
|
||||
description="Endpoint to create a submission object. Users should interact with the attempt endpoint to submit flags.",
|
||||
responses={
|
||||
200: ("Success", "SubmissionListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(TransientSubmissionModel, location="json")
|
||||
def post(self, json_args):
|
||||
req = json_args
|
||||
Model = Submissions.get_child(type=req.get("type"))
|
||||
schema = SubmissionSchema(instance=Model())
|
||||
response = schema.load(req)
|
||||
@@ -54,6 +149,16 @@ class SubmissionsList(Resource):
|
||||
@submissions_namespace.param("submission_id", "A Submission ID")
|
||||
class Submission(Resource):
|
||||
@admins_only
|
||||
@submissions_namespace.doc(
|
||||
description="Endpoint to get submission objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "SubmissionDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, submission_id):
|
||||
submission = Submissions.query.filter_by(id=submission_id).first_or_404()
|
||||
schema = SubmissionSchema()
|
||||
@@ -65,6 +170,16 @@ class Submission(Resource):
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@submissions_namespace.doc(
|
||||
description="Endpoint to get submission objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "APISimpleSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def delete(self, submission_id):
|
||||
submission = Submissions.query.filter_by(id=submission_id).first_or_404()
|
||||
db.session.delete(submission)
|
||||
|
||||
@@ -1,19 +1,70 @@
|
||||
from typing import List
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.models import build_model_filters
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import Tags, db
|
||||
from CTFd.schemas.tags import TagSchema
|
||||
from CTFd.utils.decorators import admins_only
|
||||
|
||||
tags_namespace = Namespace("tags", description="Endpoint to retrieve Tags")
|
||||
|
||||
TagModel = sqlalchemy_to_pydantic(Tags)
|
||||
|
||||
|
||||
class TagDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: TagModel
|
||||
|
||||
|
||||
class TagListSuccessResponse(APIListSuccessResponse):
|
||||
data: List[TagModel]
|
||||
|
||||
|
||||
tags_namespace.schema_model(
|
||||
"TagDetailedSuccessResponse", TagDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
tags_namespace.schema_model("TagListSuccessResponse", TagListSuccessResponse.apidoc())
|
||||
|
||||
|
||||
@tags_namespace.route("")
|
||||
class TagList(Resource):
|
||||
@admins_only
|
||||
def get(self):
|
||||
# TODO: Filter by challenge_id
|
||||
tags = Tags.query.all()
|
||||
@tags_namespace.doc(
|
||||
description="Endpoint to list Tag objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "TagListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"challenge_id": (int, None),
|
||||
"value": (str, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum(
|
||||
"TagFields", {"challenge_id": "challenge_id", "value": "value"}
|
||||
),
|
||||
None,
|
||||
),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Tags, query=q, field=field)
|
||||
|
||||
tags = Tags.query.filter_by(**query_args).filter(*filters).all()
|
||||
schema = TagSchema(many=True)
|
||||
response = schema.dump(tags)
|
||||
|
||||
@@ -23,6 +74,16 @@ class TagList(Resource):
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@tags_namespace.doc(
|
||||
description="Endpoint to create a Tag object",
|
||||
responses={
|
||||
200: ("Success", "TagDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
schema = TagSchema()
|
||||
@@ -44,6 +105,16 @@ class TagList(Resource):
|
||||
@tags_namespace.param("tag_id", "A Tag ID")
|
||||
class Tag(Resource):
|
||||
@admins_only
|
||||
@tags_namespace.doc(
|
||||
description="Endpoint to get a specific Tag object",
|
||||
responses={
|
||||
200: ("Success", "TagDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, tag_id):
|
||||
tag = Tags.query.filter_by(id=tag_id).first_or_404()
|
||||
|
||||
@@ -55,6 +126,16 @@ class Tag(Resource):
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@tags_namespace.doc(
|
||||
description="Endpoint to edit a specific Tag object",
|
||||
responses={
|
||||
200: ("Success", "TagDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def patch(self, tag_id):
|
||||
tag = Tags.query.filter_by(id=tag_id).first_or_404()
|
||||
schema = TagSchema()
|
||||
@@ -72,6 +153,10 @@ class Tag(Resource):
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@tags_namespace.doc(
|
||||
description="Endpoint to delete a specific Tag object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self, tag_id):
|
||||
tag = Tags.query.filter_by(id=tag_id).first_or_404()
|
||||
db.session.delete(tag)
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import copy
|
||||
from typing import List
|
||||
|
||||
from flask import abort, request, session
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.models import build_model_filters
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import (
|
||||
APIDetailedSuccessResponse,
|
||||
PaginatedAPIListSuccessResponse,
|
||||
)
|
||||
from CTFd.cache import clear_standings, clear_team_session, clear_user_session
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import Awards, Submissions, Teams, Unlocks, Users, db
|
||||
from CTFd.schemas.awards import AwardSchema
|
||||
from CTFd.schemas.submissions import SubmissionSchema
|
||||
@@ -17,27 +26,114 @@ from CTFd.utils.user import get_current_team, get_current_user_type, is_admin
|
||||
|
||||
teams_namespace = Namespace("teams", description="Endpoint to retrieve Teams")
|
||||
|
||||
TeamModel = sqlalchemy_to_pydantic(Teams)
|
||||
TransientTeamModel = sqlalchemy_to_pydantic(Teams, exclude=["id"])
|
||||
|
||||
|
||||
class TeamDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: TeamModel
|
||||
|
||||
|
||||
class TeamListSuccessResponse(PaginatedAPIListSuccessResponse):
|
||||
data: List[TeamModel]
|
||||
|
||||
|
||||
teams_namespace.schema_model(
|
||||
"TeamDetailedSuccessResponse", TeamDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
teams_namespace.schema_model(
|
||||
"TeamListSuccessResponse", TeamListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@teams_namespace.route("")
|
||||
class TeamList(Resource):
|
||||
@check_account_visibility
|
||||
def get(self):
|
||||
@teams_namespace.doc(
|
||||
description="Endpoint to get Team objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "TeamListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"affiliation": (str, None),
|
||||
"country": (str, None),
|
||||
"bracket": (str, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum(
|
||||
"TeamFields",
|
||||
{
|
||||
"name": "name",
|
||||
"website": "website",
|
||||
"country": "country",
|
||||
"bracket": "bracket",
|
||||
"affiliation": "affiliation",
|
||||
},
|
||||
),
|
||||
None,
|
||||
),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Teams, query=q, field=field)
|
||||
|
||||
if is_admin() and request.args.get("view") == "admin":
|
||||
teams = Teams.query.filter_by()
|
||||
teams = (
|
||||
Teams.query.filter_by(**query_args)
|
||||
.filter(*filters)
|
||||
.paginate(per_page=50, max_per_page=100)
|
||||
)
|
||||
else:
|
||||
teams = Teams.query.filter_by(hidden=False, banned=False)
|
||||
teams = (
|
||||
Teams.query.filter_by(hidden=False, banned=False, **query_args)
|
||||
.filter(*filters)
|
||||
.paginate(per_page=50, max_per_page=100)
|
||||
)
|
||||
|
||||
user_type = get_current_user_type(fallback="user")
|
||||
view = copy.deepcopy(TeamSchema.views.get(user_type))
|
||||
view.remove("members")
|
||||
response = TeamSchema(view=view, many=True).dump(teams)
|
||||
response = TeamSchema(view=view, many=True).dump(teams.items)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
return {
|
||||
"meta": {
|
||||
"pagination": {
|
||||
"page": teams.page,
|
||||
"next": teams.next_num,
|
||||
"prev": teams.prev_num,
|
||||
"pages": teams.pages,
|
||||
"per_page": teams.per_page,
|
||||
"total": teams.total,
|
||||
}
|
||||
},
|
||||
"success": True,
|
||||
"data": response.data,
|
||||
}
|
||||
|
||||
@admins_only
|
||||
@teams_namespace.doc(
|
||||
description="Endpoint to create a Team object",
|
||||
responses={
|
||||
200: ("Success", "TeamDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
user_type = get_current_user_type()
|
||||
@@ -63,6 +159,16 @@ class TeamList(Resource):
|
||||
@teams_namespace.param("team_id", "Team ID")
|
||||
class TeamPublic(Resource):
|
||||
@check_account_visibility
|
||||
@teams_namespace.doc(
|
||||
description="Endpoint to get a specific Team object",
|
||||
responses={
|
||||
200: ("Success", "TeamDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, team_id):
|
||||
team = Teams.query.filter_by(id=team_id).first_or_404()
|
||||
|
||||
@@ -82,6 +188,16 @@ class TeamPublic(Resource):
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@teams_namespace.doc(
|
||||
description="Endpoint to edit a specific Team object",
|
||||
responses={
|
||||
200: ("Success", "TeamDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def patch(self, team_id):
|
||||
team = Teams.query.filter_by(id=team_id).first_or_404()
|
||||
data = request.get_json()
|
||||
@@ -104,6 +220,10 @@ class TeamPublic(Resource):
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@teams_namespace.doc(
|
||||
description="Endpoint to delete a specific Team object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self, team_id):
|
||||
team = Teams.query.filter_by(id=team_id).first_or_404()
|
||||
team_id = team.id
|
||||
@@ -128,6 +248,16 @@ class TeamPublic(Resource):
|
||||
class TeamPrivate(Resource):
|
||||
@authed_only
|
||||
@require_team
|
||||
@teams_namespace.doc(
|
||||
description="Endpoint to get the current user's Team object",
|
||||
responses={
|
||||
200: ("Success", "TeamDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self):
|
||||
team = get_current_team()
|
||||
response = TeamSchema(view="self").dump(team)
|
||||
@@ -141,6 +271,16 @@ class TeamPrivate(Resource):
|
||||
|
||||
@authed_only
|
||||
@require_team
|
||||
@teams_namespace.doc(
|
||||
description="Endpoint to edit the current user's Team object",
|
||||
responses={
|
||||
200: ("Success", "TeamDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def patch(self):
|
||||
team = get_current_team()
|
||||
if team.captain_id != session["id"]:
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import datetime
|
||||
from typing import List
|
||||
|
||||
from flask import request, session
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||
from CTFd.models import Tokens, db
|
||||
from CTFd.schemas.tokens import TokenSchema
|
||||
from CTFd.utils.decorators import authed_only, require_verified_emails
|
||||
@@ -11,11 +14,50 @@ from CTFd.utils.user import get_current_user, get_current_user_type, is_admin
|
||||
|
||||
tokens_namespace = Namespace("tokens", description="Endpoint to retrieve Tokens")
|
||||
|
||||
TokenModel = sqlalchemy_to_pydantic(Tokens)
|
||||
ValuelessTokenModel = sqlalchemy_to_pydantic(Tokens, exclude=["value"])
|
||||
|
||||
|
||||
class TokenDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: TokenModel
|
||||
|
||||
|
||||
class ValuelessTokenDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: ValuelessTokenModel
|
||||
|
||||
|
||||
class TokenListSuccessResponse(APIListSuccessResponse):
|
||||
data: List[TokenModel]
|
||||
|
||||
|
||||
tokens_namespace.schema_model(
|
||||
"TokenDetailedSuccessResponse", TokenDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
tokens_namespace.schema_model(
|
||||
"ValuelessTokenDetailedSuccessResponse",
|
||||
ValuelessTokenDetailedSuccessResponse.apidoc(),
|
||||
)
|
||||
|
||||
tokens_namespace.schema_model(
|
||||
"TokenListSuccessResponse", TokenListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@tokens_namespace.route("")
|
||||
class TokenList(Resource):
|
||||
@require_verified_emails
|
||||
@authed_only
|
||||
@tokens_namespace.doc(
|
||||
description="Endpoint to get token objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "TokenListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self):
|
||||
user = get_current_user()
|
||||
tokens = Tokens.query.filter_by(user_id=user.id)
|
||||
@@ -30,6 +72,16 @@ class TokenList(Resource):
|
||||
|
||||
@require_verified_emails
|
||||
@authed_only
|
||||
@tokens_namespace.doc(
|
||||
description="Endpoint to create a token object",
|
||||
responses={
|
||||
200: ("Success", "TokenDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
expiration = req.get("expiration")
|
||||
@@ -54,6 +106,16 @@ class TokenList(Resource):
|
||||
class TokenDetail(Resource):
|
||||
@require_verified_emails
|
||||
@authed_only
|
||||
@tokens_namespace.doc(
|
||||
description="Endpoint to get an existing token object",
|
||||
responses={
|
||||
200: ("Success", "ValuelessTokenDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, token_id):
|
||||
if is_admin():
|
||||
token = Tokens.query.filter_by(id=token_id).first_or_404()
|
||||
@@ -73,6 +135,10 @@ class TokenDetail(Resource):
|
||||
|
||||
@require_verified_emails
|
||||
@authed_only
|
||||
@tokens_namespace.doc(
|
||||
description="Endpoint to delete an existing token object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self, token_id):
|
||||
if is_admin():
|
||||
token = Tokens.query.filter_by(id=token_id).first_or_404()
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
from typing import List
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.models import build_model_filters
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||
from CTFd.cache import clear_standings
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import Unlocks, db, get_class_by_tablename
|
||||
from CTFd.schemas.awards import AwardSchema
|
||||
from CTFd.schemas.unlocks import UnlockSchema
|
||||
@@ -15,14 +22,62 @@ from CTFd.utils.user import get_current_user
|
||||
|
||||
unlocks_namespace = Namespace("unlocks", description="Endpoint to retrieve Unlocks")
|
||||
|
||||
UnlockModel = sqlalchemy_to_pydantic(Unlocks)
|
||||
TransientUnlockModel = sqlalchemy_to_pydantic(Unlocks, exclude=["id"])
|
||||
|
||||
|
||||
class UnlockDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: UnlockModel
|
||||
|
||||
|
||||
class UnlockListSuccessResponse(APIListSuccessResponse):
|
||||
data: List[UnlockModel]
|
||||
|
||||
|
||||
unlocks_namespace.schema_model(
|
||||
"UnlockDetailedSuccessResponse", UnlockDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
unlocks_namespace.schema_model(
|
||||
"UnlockListSuccessResponse", UnlockListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@unlocks_namespace.route("")
|
||||
class UnlockList(Resource):
|
||||
@admins_only
|
||||
def get(self):
|
||||
hints = Unlocks.query.all()
|
||||
@unlocks_namespace.doc(
|
||||
description="Endpoint to get unlock objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "UnlockListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"user_id": (int, None),
|
||||
"team_id": (int, None),
|
||||
"target": (int, None),
|
||||
"type": (str, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum("UnlockFields", {"target": "target", "type": "type"}),
|
||||
None,
|
||||
),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Unlocks, query=q, field=field)
|
||||
|
||||
unlocks = Unlocks.query.filter_by(**query_args).filter(*filters).all()
|
||||
schema = UnlockSchema()
|
||||
response = schema.dump(hints)
|
||||
response = schema.dump(unlocks)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
@@ -32,6 +87,16 @@ class UnlockList(Resource):
|
||||
@during_ctf_time_only
|
||||
@require_verified_emails
|
||||
@authed_only
|
||||
@unlocks_namespace.doc(
|
||||
description="Endpoint to create an unlock object. Used to unlock hints.",
|
||||
responses={
|
||||
200: ("Success", "UnlockDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
user = get_current_user()
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
from typing import List
|
||||
|
||||
from flask import abort, request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.models import build_model_filters
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import (
|
||||
APIDetailedSuccessResponse,
|
||||
PaginatedAPIListSuccessResponse,
|
||||
)
|
||||
from CTFd.cache import clear_standings, clear_user_session
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import (
|
||||
Awards,
|
||||
Notifications,
|
||||
@@ -28,28 +38,115 @@ from CTFd.utils.user import get_current_user, get_current_user_type, is_admin
|
||||
users_namespace = Namespace("users", description="Endpoint to retrieve Users")
|
||||
|
||||
|
||||
UserModel = sqlalchemy_to_pydantic(Users)
|
||||
TransientUserModel = sqlalchemy_to_pydantic(Users, exclude=["id"])
|
||||
|
||||
|
||||
class UserDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: UserModel
|
||||
|
||||
|
||||
class UserListSuccessResponse(PaginatedAPIListSuccessResponse):
|
||||
data: List[UserModel]
|
||||
|
||||
|
||||
users_namespace.schema_model(
|
||||
"UserDetailedSuccessResponse", UserDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
users_namespace.schema_model(
|
||||
"UserListSuccessResponse", UserListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@users_namespace.route("")
|
||||
class UserList(Resource):
|
||||
@check_account_visibility
|
||||
def get(self):
|
||||
if is_admin() and request.args.get("view") == "admin":
|
||||
users = Users.query.filter_by()
|
||||
else:
|
||||
users = Users.query.filter_by(banned=False, hidden=False)
|
||||
@users_namespace.doc(
|
||||
description="Endpoint to get User objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "UserListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"affiliation": (str, None),
|
||||
"country": (str, None),
|
||||
"bracket": (str, None),
|
||||
"q": (str, None),
|
||||
"field": (
|
||||
RawEnum(
|
||||
"UserFields",
|
||||
{
|
||||
"name": "name",
|
||||
"website": "website",
|
||||
"country": "country",
|
||||
"bracket": "bracket",
|
||||
"affiliation": "affiliation",
|
||||
},
|
||||
),
|
||||
None,
|
||||
),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Users, query=q, field=field)
|
||||
|
||||
response = UserSchema(view="user", many=True).dump(users)
|
||||
if is_admin() and request.args.get("view") == "admin":
|
||||
users = (
|
||||
Users.query.filter_by(**query_args)
|
||||
.filter(*filters)
|
||||
.paginate(per_page=50, max_per_page=100)
|
||||
)
|
||||
else:
|
||||
users = (
|
||||
Users.query.filter_by(banned=False, hidden=False, **query_args)
|
||||
.filter(*filters)
|
||||
.paginate(per_page=50, max_per_page=100)
|
||||
)
|
||||
|
||||
response = UserSchema(view="user", many=True).dump(users.items)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
return {
|
||||
"meta": {
|
||||
"pagination": {
|
||||
"page": users.page,
|
||||
"next": users.next_num,
|
||||
"prev": users.prev_num,
|
||||
"pages": users.pages,
|
||||
"per_page": users.per_page,
|
||||
"total": users.total,
|
||||
}
|
||||
},
|
||||
"success": True,
|
||||
"data": response.data,
|
||||
}
|
||||
|
||||
@users_namespace.doc()
|
||||
@admins_only
|
||||
@users_namespace.doc(
|
||||
description="Endpoint to create a User object",
|
||||
responses={
|
||||
200: ("Success", "UserDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
params={
|
||||
"notify": "Whether to send the created user an email with their credentials"
|
||||
}
|
||||
},
|
||||
)
|
||||
@admins_only
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
schema = UserSchema("admin")
|
||||
@@ -79,6 +176,16 @@ class UserList(Resource):
|
||||
@users_namespace.param("user_id", "User ID")
|
||||
class UserPublic(Resource):
|
||||
@check_account_visibility
|
||||
@users_namespace.doc(
|
||||
description="Endpoint to get a specific User object",
|
||||
responses={
|
||||
200: ("Success", "UserDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, user_id):
|
||||
user = Users.query.filter_by(id=user_id).first_or_404()
|
||||
|
||||
@@ -97,6 +204,16 @@ class UserPublic(Resource):
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@users_namespace.doc(
|
||||
description="Endpoint to edit a specific User object",
|
||||
responses={
|
||||
200: ("Success", "UserDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def patch(self, user_id):
|
||||
user = Users.query.filter_by(id=user_id).first_or_404()
|
||||
data = request.get_json()
|
||||
@@ -118,6 +235,10 @@ class UserPublic(Resource):
|
||||
return {"success": True, "data": response}
|
||||
|
||||
@admins_only
|
||||
@users_namespace.doc(
|
||||
description="Endpoint to delete a specific User object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self, user_id):
|
||||
Notifications.query.filter_by(user_id=user_id).delete()
|
||||
Awards.query.filter_by(user_id=user_id).delete()
|
||||
@@ -138,6 +259,16 @@ class UserPublic(Resource):
|
||||
@users_namespace.route("/me")
|
||||
class UserPrivate(Resource):
|
||||
@authed_only
|
||||
@users_namespace.doc(
|
||||
description="Endpoint to get the User object for the current user",
|
||||
responses={
|
||||
200: ("Success", "UserDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self):
|
||||
user = get_current_user()
|
||||
response = UserSchema("self").dump(user).data
|
||||
@@ -146,6 +277,16 @@ class UserPrivate(Resource):
|
||||
return {"success": True, "data": response}
|
||||
|
||||
@authed_only
|
||||
@users_namespace.doc(
|
||||
description="Endpoint to edit the User object for the current user",
|
||||
responses={
|
||||
200: ("Success", "UserDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def patch(self):
|
||||
user = get_current_user()
|
||||
data = request.get_json()
|
||||
@@ -294,6 +435,10 @@ class UserPublicAwards(Resource):
|
||||
@users_namespace.param("user_id", "User ID")
|
||||
class UserEmails(Resource):
|
||||
@admins_only
|
||||
@users_namespace.doc(
|
||||
description="Endpoint to email a User object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
@ratelimit(method="POST", limit=10, interval=60)
|
||||
def post(self, user_id):
|
||||
req = request.get_json()
|
||||
@@ -314,4 +459,4 @@ class UserEmails(Resource):
|
||||
|
||||
result, response = sendmail(addr=user.email, text=text)
|
||||
|
||||
return {"success": result, "data": {}}
|
||||
return {"success": result}
|
||||
|
||||
36
CTFd/auth.py
36
CTFd/auth.py
@@ -6,10 +6,10 @@ from flask import current_app as app
|
||||
from flask import redirect, render_template, request, session, url_for
|
||||
from itsdangerous.exc import BadSignature, BadTimeSignature, SignatureExpired
|
||||
|
||||
from CTFd.cache import clear_team_session, clear_user_session
|
||||
from CTFd.models import Teams, Users, db
|
||||
from CTFd.utils import config, email, get_app_config, get_config
|
||||
from CTFd.utils import user as current_user
|
||||
from CTFd.cache import clear_user_session, clear_team_session
|
||||
from CTFd.utils import validators
|
||||
from CTFd.utils.config import is_teams_mode
|
||||
from CTFd.utils.config.integrations import mlc_registration
|
||||
@@ -17,7 +17,7 @@ from CTFd.utils.config.visibility import registration_visible
|
||||
from CTFd.utils.crypto import verify_password
|
||||
from CTFd.utils.decorators import ratelimit
|
||||
from CTFd.utils.decorators.visibility import check_registration_visibility
|
||||
from CTFd.utils.helpers import error_for, get_errors
|
||||
from CTFd.utils.helpers import error_for, get_errors, markup
|
||||
from CTFd.utils.logging import log
|
||||
from CTFd.utils.modes import TEAMS_MODE
|
||||
from CTFd.utils.security.auth import login_user, logout_user
|
||||
@@ -66,7 +66,7 @@ def confirm(data=None):
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
# User is trying to start or restart the confirmation flow
|
||||
if not current_user.authed():
|
||||
if current_user.authed() is False:
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
user = Users.query.filter_by(id=session["id"]).first_or_404()
|
||||
@@ -82,19 +82,27 @@ def confirm(data=None):
|
||||
format="[{date}] {ip} - {name} initiated a confirmation email resend",
|
||||
)
|
||||
return render_template(
|
||||
"confirm.html",
|
||||
user=user,
|
||||
infos=["Your confirmation email has been resent!"],
|
||||
"confirm.html", infos=[f"Confirmation email sent to {user.email}!"]
|
||||
)
|
||||
elif request.method == "GET":
|
||||
# User has been directed to the confirm page
|
||||
return render_template("confirm.html", user=user)
|
||||
return render_template("confirm.html")
|
||||
|
||||
|
||||
@auth.route("/reset_password", methods=["POST", "GET"])
|
||||
@auth.route("/reset_password/<data>", methods=["POST", "GET"])
|
||||
@ratelimit(method="POST", limit=10, interval=60)
|
||||
def reset_password(data=None):
|
||||
if config.can_send_mail() is False:
|
||||
return render_template(
|
||||
"reset_password.html",
|
||||
errors=[
|
||||
markup(
|
||||
"This CTF is not configured to send email.<br> Please contact an organizer to have your password reset."
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if data is not None:
|
||||
try:
|
||||
email_address = unserialize(data, max_age=1800)
|
||||
@@ -115,7 +123,7 @@ def reset_password(data=None):
|
||||
if user.oauth_id:
|
||||
return render_template(
|
||||
"reset_password.html",
|
||||
errors=[
|
||||
infos=[
|
||||
"Your account was registered via an authentication provider and does not have an associated password. Please login via your authentication provider."
|
||||
],
|
||||
)
|
||||
@@ -144,16 +152,10 @@ def reset_password(data=None):
|
||||
|
||||
get_errors()
|
||||
|
||||
if config.can_send_mail() is False:
|
||||
return render_template(
|
||||
"reset_password.html",
|
||||
errors=["Email could not be sent due to server misconfiguration"],
|
||||
)
|
||||
|
||||
if not user:
|
||||
return render_template(
|
||||
"reset_password.html",
|
||||
errors=[
|
||||
infos=[
|
||||
"If that account exists you will receive an email, please check your inbox"
|
||||
],
|
||||
)
|
||||
@@ -161,7 +163,7 @@ def reset_password(data=None):
|
||||
if user.oauth_id:
|
||||
return render_template(
|
||||
"reset_password.html",
|
||||
errors=[
|
||||
infos=[
|
||||
"The email address associated with this account was registered via an authentication provider and does not have an associated password. Please login via your authentication provider."
|
||||
],
|
||||
)
|
||||
@@ -170,7 +172,7 @@ def reset_password(data=None):
|
||||
|
||||
return render_template(
|
||||
"reset_password.html",
|
||||
errors=[
|
||||
infos=[
|
||||
"If that account exists you will receive an email, please check your inbox"
|
||||
],
|
||||
)
|
||||
|
||||
17
CTFd/cache/__init__.py
vendored
17
CTFd/cache/__init__.py
vendored
@@ -30,14 +30,31 @@ def clear_standings():
|
||||
from CTFd.utils.scores import get_standings, get_team_standings, get_user_standings
|
||||
from CTFd.api.v1.scoreboard import ScoreboardDetail, ScoreboardList
|
||||
from CTFd.api import api
|
||||
from CTFd.utils.user import (
|
||||
get_user_score,
|
||||
get_user_place,
|
||||
get_team_score,
|
||||
get_team_place,
|
||||
)
|
||||
|
||||
# Clear out the bulk standings functions
|
||||
cache.delete_memoized(get_standings)
|
||||
cache.delete_memoized(get_team_standings)
|
||||
cache.delete_memoized(get_user_standings)
|
||||
|
||||
# Clear out the individual helpers for accessing score via the model
|
||||
cache.delete_memoized(Users.get_score)
|
||||
cache.delete_memoized(Users.get_place)
|
||||
cache.delete_memoized(Teams.get_score)
|
||||
cache.delete_memoized(Teams.get_place)
|
||||
|
||||
# Clear the Jinja Attrs constants
|
||||
cache.delete_memoized(get_user_score)
|
||||
cache.delete_memoized(get_user_place)
|
||||
cache.delete_memoized(get_team_score)
|
||||
cache.delete_memoized(get_team_place)
|
||||
|
||||
# Clear out HTTP request responses
|
||||
cache.delete(make_cache_key(path="scoreboard.listing"))
|
||||
cache.delete(make_cache_key(path=api.name + "." + ScoreboardList.endpoint))
|
||||
cache.delete(make_cache_key(path=api.name + "." + ScoreboardDetail.endpoint))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from flask import Blueprint, render_template
|
||||
|
||||
from CTFd.utils import config, get_config
|
||||
from CTFd.utils.dates import ctf_ended, ctf_paused, view_after_ctf
|
||||
from CTFd.utils import config
|
||||
from CTFd.utils.dates import ctf_ended, ctf_paused, ctf_started
|
||||
from CTFd.utils.decorators import (
|
||||
during_ctf_time_only,
|
||||
require_team,
|
||||
@@ -21,16 +21,14 @@ challenges = Blueprint("challenges", __name__)
|
||||
def listing():
|
||||
infos = get_infos()
|
||||
errors = get_errors()
|
||||
start = get_config("start") or 0
|
||||
end = get_config("end") or 0
|
||||
|
||||
if ctf_paused():
|
||||
infos.append("{} is paused".format(config.ctf_name()))
|
||||
if ctf_started() is False:
|
||||
errors.append(f"{config.ctf_name()} has not started yet")
|
||||
|
||||
# CTF has ended but we want to allow view_after_ctf. Show error but let JS load challenges.
|
||||
if ctf_ended() and view_after_ctf():
|
||||
infos.append("{} has ended".format(config.ctf_name()))
|
||||
if ctf_paused() is True:
|
||||
infos.append(f"{config.ctf_name()} is paused")
|
||||
|
||||
return render_template(
|
||||
"challenges.html", infos=infos, errors=errors, start=int(start), end=int(end)
|
||||
)
|
||||
if ctf_ended() is True:
|
||||
infos.append(f"{config.ctf_name()} has ended")
|
||||
|
||||
return render_template("challenges.html", infos=infos, errors=errors)
|
||||
|
||||
48
CTFd/config.ini
Normal file
48
CTFd/config.ini
Normal file
@@ -0,0 +1,48 @@
|
||||
[server]
|
||||
SECRET_KEY =
|
||||
DATABASE_URL =
|
||||
REDIS_URL =
|
||||
|
||||
[security]
|
||||
SESSION_COOKIE_HTTPONLY = true
|
||||
SESSION_COOKIE_SAMESITE = Lax
|
||||
PERMANENT_SESSION_LIFETIME = 604800
|
||||
TRUSTED_PROXIES =
|
||||
|
||||
[email]
|
||||
MAILFROM_ADDR =
|
||||
MAIL_SERVER =
|
||||
MAIL_PORT =
|
||||
MAIL_USEAUTH =
|
||||
MAIL_USERNAME =
|
||||
MAIL_PASSWORD =
|
||||
MAIL_TLS =
|
||||
MAIL_SSL =
|
||||
MAILGUN_API_KEY =
|
||||
MAILGUN_BASE_URL =
|
||||
|
||||
[uploads]
|
||||
UPLOAD_PROVIDER = filesystem
|
||||
UPLOAD_FOLDER =
|
||||
AWS_ACCESS_KEY_ID =
|
||||
AWS_SECRET_ACCESS_KEY =
|
||||
AWS_S3_BUCKET =
|
||||
AWS_S3_ENDPOINT_URL =
|
||||
|
||||
[logs]
|
||||
LOG_FOLDER =
|
||||
|
||||
[optional]
|
||||
REVERSE_PROXY =
|
||||
TEMPLATES_AUTO_RELOAD =
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS =
|
||||
SWAGGER_UI =
|
||||
UPDATE_CHECK =
|
||||
APPLICATION_ROOT =
|
||||
SERVER_SENT_EVENTS =
|
||||
SQLALCHEMY_MAX_OVERFLOW =
|
||||
SQLALCHEMY_POOL_PRE_PING =
|
||||
|
||||
[oauth]
|
||||
OAUTH_CLIENT_ID =
|
||||
OAUTH_CLIENT_SECRET =
|
||||
215
CTFd/config.py
215
CTFd/config.py
@@ -1,8 +1,28 @@
|
||||
import configparser
|
||||
import os
|
||||
from distutils.util import strtobool
|
||||
|
||||
""" GENERATE SECRET KEY """
|
||||
|
||||
if not os.getenv("SECRET_KEY"):
|
||||
def process_boolean_str(value):
|
||||
if type(value) is bool:
|
||||
return value
|
||||
|
||||
if value is None:
|
||||
return False
|
||||
|
||||
if value == "":
|
||||
return None
|
||||
|
||||
return bool(strtobool(value))
|
||||
|
||||
|
||||
def empty_str_cast(value, default=None):
|
||||
if value == "":
|
||||
return default
|
||||
return value
|
||||
|
||||
|
||||
def gen_secret_key():
|
||||
# Attempt to read the secret from the secret file
|
||||
# This will fail if the secret has not been written
|
||||
try:
|
||||
@@ -21,11 +41,15 @@ if not os.getenv("SECRET_KEY"):
|
||||
secret.flush()
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
return key
|
||||
|
||||
|
||||
""" SERVER SETTINGS """
|
||||
config_ini = configparser.ConfigParser()
|
||||
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.ini")
|
||||
config_ini.read(path)
|
||||
|
||||
|
||||
# fmt: off
|
||||
class Config(object):
|
||||
"""
|
||||
CTFd Configuration Object
|
||||
@@ -62,33 +86,37 @@ class Config(object):
|
||||
e.g. redis://user:password@localhost:6379
|
||||
http://pythonhosted.org/Flask-Caching/#configuring-flask-caching
|
||||
"""
|
||||
SECRET_KEY = os.getenv("SECRET_KEY") or key
|
||||
DATABASE_URL = os.getenv("DATABASE_URL") or "sqlite:///{}/ctfd.db".format(
|
||||
os.path.dirname(os.path.abspath(__file__))
|
||||
)
|
||||
REDIS_URL = os.getenv("REDIS_URL")
|
||||
SECRET_KEY: str = os.getenv("SECRET_KEY") \
|
||||
or empty_str_cast(config_ini["server"]["SECRET_KEY"]) \
|
||||
or gen_secret_key()
|
||||
|
||||
DATABASE_URL: str = os.getenv("DATABASE_URL") \
|
||||
or empty_str_cast(config_ini["server"]["DATABASE_URL"]) \
|
||||
or f"sqlite:///{os.path.dirname(os.path.abspath(__file__))}/ctfd.db"
|
||||
|
||||
REDIS_URL: str = os.getenv("REDIS_URL") \
|
||||
or empty_str_cast(config_ini["server"]["REDIS_URL"])
|
||||
|
||||
SQLALCHEMY_DATABASE_URI = DATABASE_URL
|
||||
CACHE_REDIS_URL = REDIS_URL
|
||||
if CACHE_REDIS_URL:
|
||||
CACHE_TYPE = "redis"
|
||||
CACHE_TYPE: str = "redis"
|
||||
else:
|
||||
CACHE_TYPE = "filesystem"
|
||||
CACHE_DIR = os.path.join(
|
||||
CACHE_TYPE: str = "filesystem"
|
||||
CACHE_DIR: str = os.path.join(
|
||||
os.path.dirname(__file__), os.pardir, ".data", "filesystem_cache"
|
||||
)
|
||||
CACHE_THRESHOLD = (
|
||||
0
|
||||
) # Override the threshold of cached values on the filesystem. The default is 500. Don't change unless you know what you're doing.
|
||||
# Override the threshold of cached values on the filesystem. The default is 500. Don't change unless you know what you're doing.
|
||||
CACHE_THRESHOLD: int = 0
|
||||
|
||||
"""
|
||||
=== SECURITY ===
|
||||
|
||||
SESSION_COOKIE_HTTPONLY:
|
||||
Controls if cookies should be set with the HttpOnly flag.
|
||||
Controls if cookies should be set with the HttpOnly flag. Defaults to True
|
||||
|
||||
PERMANENT_SESSION_LIFETIME:
|
||||
The lifetime of a session. The default is 604800 seconds.
|
||||
The lifetime of a session. The default is 604800 seconds (7 days).
|
||||
|
||||
TRUSTED_PROXIES:
|
||||
Defines a set of regular expressions used for finding a user's IP address if the CTFd instance
|
||||
@@ -98,11 +126,18 @@ class Config(object):
|
||||
CTFd only uses IP addresses for cursory tracking purposes. It is ill-advised to do anything complicated based
|
||||
solely on IP addresses unless you know what you are doing.
|
||||
"""
|
||||
SESSION_COOKIE_HTTPONLY = not os.getenv("SESSION_COOKIE_HTTPONLY") # Defaults True
|
||||
SESSION_COOKIE_SAMESITE = os.getenv("SESSION_COOKIE_SAMESITE") or "Lax"
|
||||
PERMANENT_SESSION_LIFETIME = int(
|
||||
os.getenv("PERMANENT_SESSION_LIFETIME") or 604800
|
||||
) # 7 days in seconds
|
||||
SESSION_COOKIE_HTTPONLY: bool = process_boolean_str(os.getenv("SESSION_COOKIE_HTTPONLY")) \
|
||||
or config_ini["security"].getboolean("SESSION_COOKIE_HTTPONLY") \
|
||||
or True
|
||||
|
||||
SESSION_COOKIE_SAMESITE: str = os.getenv("SESSION_COOKIE_SAMESITE") \
|
||||
or empty_str_cast(config_ini["security"]["SESSION_COOKIE_SAMESITE"]) \
|
||||
or "Lax"
|
||||
|
||||
PERMANENT_SESSION_LIFETIME: int = int(os.getenv("PERMANENT_SESSION_LIFETIME", 0)) \
|
||||
or config_ini["security"].getint("PERMANENT_SESSION_LIFETIME") \
|
||||
or 604800
|
||||
|
||||
TRUSTED_PROXIES = [
|
||||
r"^127\.0\.0\.1$",
|
||||
# Remove the following proxies if you do not trust the local network
|
||||
@@ -143,21 +178,43 @@ class Config(object):
|
||||
Whether to connect to the SMTP server over SSL
|
||||
|
||||
MAILGUN_API_KEY
|
||||
Mailgun API key to send email over Mailgun
|
||||
Mailgun API key to send email over Mailgun. As of CTFd v3, Mailgun integration is deprecated.
|
||||
Installations using the Mailgun API should migrate over to SMTP settings.
|
||||
|
||||
MAILGUN_BASE_URL
|
||||
Mailgun base url to send email over Mailgun
|
||||
Mailgun base url to send email over Mailgun. As of CTFd v3, Mailgun integration is deprecated.
|
||||
Installations using the Mailgun API should migrate over to SMTP settings.
|
||||
"""
|
||||
MAILFROM_ADDR = os.getenv("MAILFROM_ADDR") or "noreply@ctfd.io"
|
||||
MAIL_SERVER = os.getenv("MAIL_SERVER") or None
|
||||
MAIL_PORT = os.getenv("MAIL_PORT")
|
||||
MAIL_USEAUTH = os.getenv("MAIL_USEAUTH")
|
||||
MAIL_USERNAME = os.getenv("MAIL_USERNAME")
|
||||
MAIL_PASSWORD = os.getenv("MAIL_PASSWORD")
|
||||
MAIL_TLS = os.getenv("MAIL_TLS") or False
|
||||
MAIL_SSL = os.getenv("MAIL_SSL") or False
|
||||
MAILGUN_API_KEY = os.getenv("MAILGUN_API_KEY")
|
||||
MAILGUN_BASE_URL = os.getenv("MAILGUN_BASE_URL")
|
||||
MAILFROM_ADDR: str = os.getenv("MAILFROM_ADDR") \
|
||||
or config_ini["email"]["MAILFROM_ADDR"] \
|
||||
or "noreply@ctfd.io"
|
||||
|
||||
MAIL_SERVER: str = os.getenv("MAIL_SERVER") \
|
||||
or empty_str_cast(config_ini["email"]["MAIL_SERVER"])
|
||||
|
||||
MAIL_PORT: str = os.getenv("MAIL_PORT") \
|
||||
or empty_str_cast(config_ini["email"]["MAIL_PORT"])
|
||||
|
||||
MAIL_USEAUTH: bool = process_boolean_str(os.getenv("MAIL_USEAUTH")) \
|
||||
or process_boolean_str(config_ini["email"]["MAIL_USEAUTH"])
|
||||
|
||||
MAIL_USERNAME: str = os.getenv("MAIL_USERNAME") \
|
||||
or empty_str_cast(config_ini["email"]["MAIL_USERNAME"])
|
||||
|
||||
MAIL_PASSWORD: str = os.getenv("MAIL_PASSWORD") \
|
||||
or empty_str_cast(config_ini["email"]["MAIL_PASSWORD"])
|
||||
|
||||
MAIL_TLS: bool = process_boolean_str(os.getenv("MAIL_TLS")) \
|
||||
or process_boolean_str(config_ini["email"]["MAIL_TLS"])
|
||||
|
||||
MAIL_SSL: bool = process_boolean_str(os.getenv("MAIL_SSL")) \
|
||||
or process_boolean_str(config_ini["email"]["MAIL_SSL"])
|
||||
|
||||
MAILGUN_API_KEY: str = os.getenv("MAILGUN_API_KEY") \
|
||||
or empty_str_cast(config_ini["email"]["MAILGUN_API_KEY"])
|
||||
|
||||
MAILGUN_BASE_URL: str = os.getenv("MAILGUN_BASE_URL") \
|
||||
or empty_str_cast(config_ini["email"]["MAILGUN_API_KEY"])
|
||||
|
||||
"""
|
||||
=== LOGS ===
|
||||
@@ -165,9 +222,9 @@ class Config(object):
|
||||
The location where logs are written. These are the logs for CTFd key submissions, registrations, and logins.
|
||||
The default location is the CTFd/logs folder.
|
||||
"""
|
||||
LOG_FOLDER = os.getenv("LOG_FOLDER") or os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "logs"
|
||||
)
|
||||
LOG_FOLDER: str = os.getenv("LOG_FOLDER") \
|
||||
or empty_str_cast(config_ini["logs"]["LOG_FOLDER"]) \
|
||||
or os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
|
||||
|
||||
"""
|
||||
=== UPLOADS ===
|
||||
@@ -191,15 +248,26 @@ class Config(object):
|
||||
A URL pointing to a custom S3 implementation.
|
||||
|
||||
"""
|
||||
UPLOAD_PROVIDER = os.getenv("UPLOAD_PROVIDER") or "filesystem"
|
||||
UPLOAD_FOLDER = os.getenv("UPLOAD_FOLDER") or os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "uploads"
|
||||
)
|
||||
UPLOAD_PROVIDER: str = os.getenv("UPLOAD_PROVIDER") \
|
||||
or empty_str_cast(config_ini["uploads"]["UPLOAD_PROVIDER"]) \
|
||||
or "filesystem"
|
||||
|
||||
UPLOAD_FOLDER: str = os.getenv("UPLOAD_FOLDER") \
|
||||
or empty_str_cast(config_ini["uploads"]["UPLOAD_FOLDER"]) \
|
||||
or os.path.join(os.path.dirname(os.path.abspath(__file__)), "uploads")
|
||||
|
||||
if UPLOAD_PROVIDER == "s3":
|
||||
AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID")
|
||||
AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY")
|
||||
AWS_S3_BUCKET = os.getenv("AWS_S3_BUCKET")
|
||||
AWS_S3_ENDPOINT_URL = os.getenv("AWS_S3_ENDPOINT_URL")
|
||||
AWS_ACCESS_KEY_ID: str = os.getenv("AWS_ACCESS_KEY_ID") \
|
||||
or empty_str_cast(config_ini["uploads"]["AWS_ACCESS_KEY_ID"])
|
||||
|
||||
AWS_SECRET_ACCESS_KEY: str = os.getenv("AWS_SECRET_ACCESS_KEY") \
|
||||
or empty_str_cast(config_ini["uploads"]["AWS_SECRET_ACCESS_KEY"])
|
||||
|
||||
AWS_S3_BUCKET: str = os.getenv("AWS_S3_BUCKET") \
|
||||
or empty_str_cast(config_ini["uploads"]["AWS_S3_BUCKET"])
|
||||
|
||||
AWS_S3_ENDPOINT_URL: str = os.getenv("AWS_S3_ENDPOINT_URL") \
|
||||
or empty_str_cast(config_ini["uploads"]["AWS_S3_ENDPOINT_URL"])
|
||||
|
||||
"""
|
||||
=== OPTIONAL ===
|
||||
@@ -214,16 +282,16 @@ class Config(object):
|
||||
Alternatively if you specify `true` CTFd will default to the above behavior with all proxy settings set to 1.
|
||||
|
||||
TEMPLATES_AUTO_RELOAD:
|
||||
Specifies whether Flask should check for modifications to templates and reload them automatically.
|
||||
Specifies whether Flask should check for modifications to templates and reload them automatically. Defaults True.
|
||||
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS:
|
||||
Automatically disabled to suppress warnings and save memory. You should only enable this if you need it.
|
||||
Automatically disabled to suppress warnings and save memory. You should only enable this if you need it. Defaults False.
|
||||
|
||||
SWAGGER_UI:
|
||||
Enable the Swagger UI endpoint at /api/v1/
|
||||
|
||||
UPDATE_CHECK:
|
||||
Specifies whether or not CTFd will check whether or not there is a new version of CTFd
|
||||
Specifies whether or not CTFd will check whether or not there is a new version of CTFd. Defaults True.
|
||||
|
||||
APPLICATION_ROOT:
|
||||
Specifies what path CTFd is mounted under. It can be used to run CTFd in a subdirectory.
|
||||
@@ -237,18 +305,44 @@ class Config(object):
|
||||
https://docs.sqlalchemy.org/en/13/core/engines.html#sqlalchemy.create_engine
|
||||
https://flask-sqlalchemy.palletsprojects.com/en/2.x/config/#configuration-keys
|
||||
"""
|
||||
REVERSE_PROXY = os.getenv("REVERSE_PROXY") or False
|
||||
TEMPLATES_AUTO_RELOAD = not os.getenv("TEMPLATES_AUTO_RELOAD") # Defaults True
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = (
|
||||
os.getenv("SQLALCHEMY_TRACK_MODIFICATIONS") is not None
|
||||
) # Defaults False
|
||||
SWAGGER_UI = "/" if os.getenv("SWAGGER_UI") is not None else False # Defaults False
|
||||
UPDATE_CHECK = not os.getenv("UPDATE_CHECK") # Defaults True
|
||||
APPLICATION_ROOT = os.getenv("APPLICATION_ROOT") or "/"
|
||||
SERVER_SENT_EVENTS = not os.getenv("SERVER_SENT_EVENTS") # Defaults True
|
||||
REVERSE_PROXY: bool = process_boolean_str(os.getenv("REVERSE_PROXY")) \
|
||||
or empty_str_cast(config_ini["optional"]["REVERSE_PROXY"]) \
|
||||
or False
|
||||
|
||||
TEMPLATES_AUTO_RELOAD: bool = process_boolean_str(os.getenv("TEMPLATES_AUTO_RELOAD")) \
|
||||
or empty_str_cast(config_ini["optional"]["TEMPLATES_AUTO_RELOAD"]) \
|
||||
or True
|
||||
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS: bool = process_boolean_str(os.getenv("SQLALCHEMY_TRACK_MODIFICATIONS")) \
|
||||
or empty_str_cast(config_ini["optional"]["SQLALCHEMY_TRACK_MODIFICATIONS"]) \
|
||||
or False
|
||||
|
||||
SWAGGER_UI: bool = os.getenv("SWAGGER_UI") \
|
||||
or empty_str_cast(config_ini["optional"]["SWAGGER_UI"]) \
|
||||
or False
|
||||
|
||||
SWAGGER_UI_ENDPOINT: str = "/" if SWAGGER_UI else None
|
||||
|
||||
UPDATE_CHECK: bool = process_boolean_str(os.getenv("UPDATE_CHECK")) \
|
||||
or empty_str_cast(config_ini["optional"]["UPDATE_CHECK"]) \
|
||||
or True
|
||||
|
||||
APPLICATION_ROOT: str = os.getenv("APPLICATION_ROOT") \
|
||||
or empty_str_cast(config_ini["optional"]["APPLICATION_ROOT"]) \
|
||||
or "/"
|
||||
|
||||
SERVER_SENT_EVENTS: bool = process_boolean_str(os.getenv("SERVER_SENT_EVENTS")) \
|
||||
or empty_str_cast(config_ini["optional"]["SERVER_SENT_EVENTS"]) \
|
||||
or True
|
||||
|
||||
if DATABASE_URL.startswith("sqlite") is False:
|
||||
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||
"max_overflow": int(os.getenv("SQLALCHEMY_MAX_OVERFLOW", 20))
|
||||
"max_overflow": int(os.getenv("SQLALCHEMY_MAX_OVERFLOW", 0))
|
||||
or int(empty_str_cast(config_ini["optional"]["SQLALCHEMY_MAX_OVERFLOW"], default=0)) # noqa: E131
|
||||
or 20, # noqa: E131
|
||||
"pool_pre_ping": process_boolean_str(os.getenv("SQLALCHEMY_POOL_PRE_PING"))
|
||||
or empty_str_cast(config_ini["optional"]["SQLALCHEMY_POOL_PRE_PING"]) # noqa: E131
|
||||
or True, # noqa: E131
|
||||
}
|
||||
|
||||
"""
|
||||
@@ -257,8 +351,11 @@ class Config(object):
|
||||
MajorLeagueCyber Integration
|
||||
Register an event at https://majorleaguecyber.org/ and use the Client ID and Client Secret here
|
||||
"""
|
||||
OAUTH_CLIENT_ID = os.getenv("OAUTH_CLIENT_ID")
|
||||
OAUTH_CLIENT_SECRET = os.getenv("OAUTH_CLIENT_SECRET")
|
||||
OAUTH_CLIENT_ID: str = os.getenv("OAUTH_CLIENT_ID") \
|
||||
or empty_str_cast(config_ini["oauth"]["OAUTH_CLIENT_ID"])
|
||||
OAUTH_CLIENT_SECRET: str = os.getenv("OAUTH_CLIENT_SECRET") \
|
||||
or empty_str_cast(config_ini["oauth"]["OAUTH_CLIENT_SECRET"])
|
||||
# fmt: on
|
||||
|
||||
|
||||
class TestingConfig(Config):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from enum import Enum
|
||||
|
||||
from flask import current_app
|
||||
|
||||
JS_ENUMS = {}
|
||||
|
||||
67
CTFd/constants/config.py
Normal file
67
CTFd/constants/config.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import json
|
||||
|
||||
from CTFd.constants import JinjaEnum, RawEnum
|
||||
from CTFd.utils import get_config
|
||||
|
||||
|
||||
class ConfigTypes(str, RawEnum):
|
||||
CHALLENGE_VISIBILITY = "challenge_visibility"
|
||||
SCORE_VISIBILITY = "score_visibility"
|
||||
ACCOUNT_VISIBILITY = "account_visibility"
|
||||
REGISTRATION_VISIBILITY = "registration_visibility"
|
||||
|
||||
|
||||
@JinjaEnum
|
||||
class ChallengeVisibilityTypes(str, RawEnum):
|
||||
PUBLIC = "public"
|
||||
PRIVATE = "private"
|
||||
ADMINS = "admins"
|
||||
|
||||
|
||||
@JinjaEnum
|
||||
class ScoreVisibilityTypes(str, RawEnum):
|
||||
PUBLIC = "public"
|
||||
PRIVATE = "private"
|
||||
HIDDEN = "hidden"
|
||||
ADMINS = "admins"
|
||||
|
||||
|
||||
@JinjaEnum
|
||||
class AccountVisibilityTypes(str, RawEnum):
|
||||
PUBLIC = "public"
|
||||
PRIVATE = "private"
|
||||
ADMINS = "admins"
|
||||
|
||||
|
||||
@JinjaEnum
|
||||
class RegistrationVisibilityTypes(str, RawEnum):
|
||||
PUBLIC = "public"
|
||||
PRIVATE = "private"
|
||||
|
||||
|
||||
class _ConfigsWrapper:
|
||||
def __getattr__(self, attr):
|
||||
return get_config(attr)
|
||||
|
||||
@property
|
||||
def ctf_name(self):
|
||||
return get_config("theme_header", default="CTFd")
|
||||
|
||||
@property
|
||||
def theme_header(self):
|
||||
from CTFd.utils.helpers import markup
|
||||
|
||||
return markup(get_config("theme_header", default=""))
|
||||
|
||||
@property
|
||||
def theme_footer(self):
|
||||
from CTFd.utils.helpers import markup
|
||||
|
||||
return markup(get_config("theme_footer", default=""))
|
||||
|
||||
@property
|
||||
def theme_settings(self):
|
||||
return json.loads(get_config("theme_settings", default="null"))
|
||||
|
||||
|
||||
Configs = _ConfigsWrapper()
|
||||
54
CTFd/constants/plugins.py
Normal file
54
CTFd/constants/plugins.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from flask import current_app
|
||||
|
||||
from CTFd.plugins import get_admin_plugin_menu_bar, get_user_page_menu_bar
|
||||
from CTFd.utils.helpers import markup
|
||||
from CTFd.utils.plugins import get_registered_scripts, get_registered_stylesheets
|
||||
|
||||
|
||||
class _PluginWrapper:
|
||||
@property
|
||||
def scripts(self):
|
||||
application_root = current_app.config.get("APPLICATION_ROOT")
|
||||
subdir = application_root != "/"
|
||||
scripts = []
|
||||
for script in get_registered_scripts():
|
||||
if script.startswith("http"):
|
||||
scripts.append(f'<script defer src="{script}"></script>')
|
||||
elif subdir:
|
||||
scripts.append(
|
||||
f'<script defer src="{application_root}/{script}"></script>'
|
||||
)
|
||||
else:
|
||||
scripts.append(f'<script defer src="{script}"></script>')
|
||||
return markup("\n".join(scripts))
|
||||
|
||||
@property
|
||||
def styles(self):
|
||||
application_root = current_app.config.get("APPLICATION_ROOT")
|
||||
subdir = application_root != "/"
|
||||
_styles = []
|
||||
for stylesheet in get_registered_stylesheets():
|
||||
if stylesheet.startswith("http"):
|
||||
_styles.append(
|
||||
f'<link rel="stylesheet" type="text/css" href="{stylesheet}">'
|
||||
)
|
||||
elif subdir:
|
||||
_styles.append(
|
||||
f'<link rel="stylesheet" type="text/css" href="{application_root}/{stylesheet}">'
|
||||
)
|
||||
else:
|
||||
_styles.append(
|
||||
f'<link rel="stylesheet" type="text/css" href="{stylesheet}">'
|
||||
)
|
||||
return markup("\n".join(_styles))
|
||||
|
||||
@property
|
||||
def user_menu_pages(self):
|
||||
return get_user_page_menu_bar()
|
||||
|
||||
@property
|
||||
def admin_menu_pages(self):
|
||||
return get_admin_plugin_menu_bar()
|
||||
|
||||
|
||||
Plugins = _PluginWrapper()
|
||||
18
CTFd/constants/sessions.py
Normal file
18
CTFd/constants/sessions.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from flask import session
|
||||
|
||||
|
||||
class _SessionWrapper:
|
||||
@property
|
||||
def id(self):
|
||||
return session.get("id", 0)
|
||||
|
||||
@property
|
||||
def nonce(self):
|
||||
return session.get("nonce")
|
||||
|
||||
@property
|
||||
def hash(self):
|
||||
return session.get("hash")
|
||||
|
||||
|
||||
Session = _SessionWrapper()
|
||||
@@ -18,3 +18,26 @@ TeamAttrs = namedtuple(
|
||||
"created",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class _TeamAttrsWrapper:
|
||||
def __getattr__(self, attr):
|
||||
from CTFd.utils.user import get_current_team_attrs
|
||||
|
||||
attrs = get_current_team_attrs()
|
||||
return getattr(attrs, attr, None)
|
||||
|
||||
@property
|
||||
def place(self):
|
||||
from CTFd.utils.user import get_team_place
|
||||
|
||||
return get_team_place(team_id=self.id)
|
||||
|
||||
@property
|
||||
def score(self):
|
||||
from CTFd.utils.user import get_team_score
|
||||
|
||||
return get_team_score(team_id=self.id)
|
||||
|
||||
|
||||
Team = _TeamAttrsWrapper()
|
||||
|
||||
@@ -20,3 +20,26 @@ UserAttrs = namedtuple(
|
||||
"created",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class _UserAttrsWrapper:
|
||||
def __getattr__(self, attr):
|
||||
from CTFd.utils.user import get_current_user_attrs
|
||||
|
||||
attrs = get_current_user_attrs()
|
||||
return getattr(attrs, attr, None)
|
||||
|
||||
@property
|
||||
def place(self):
|
||||
from CTFd.utils.user import get_user_place
|
||||
|
||||
return get_user_place(user_id=self.id)
|
||||
|
||||
@property
|
||||
def score(self):
|
||||
from CTFd.utils.user import get_user_score
|
||||
|
||||
return get_user_score(user_id=self.id)
|
||||
|
||||
|
||||
User = _UserAttrsWrapper()
|
||||
|
||||
49
CTFd/forms/__init__.py
Normal file
49
CTFd/forms/__init__.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from wtforms import Form
|
||||
from wtforms.csrf.core import CSRF
|
||||
|
||||
|
||||
class CTFdCSRF(CSRF):
|
||||
def generate_csrf_token(self, csrf_token_field):
|
||||
from flask import session
|
||||
|
||||
return session.get("nonce")
|
||||
|
||||
|
||||
class BaseForm(Form):
|
||||
class Meta:
|
||||
csrf = True
|
||||
csrf_class = CTFdCSRF
|
||||
csrf_field_name = "nonce"
|
||||
|
||||
|
||||
class _FormsWrapper:
|
||||
pass
|
||||
|
||||
|
||||
Forms = _FormsWrapper()
|
||||
|
||||
from CTFd.forms import auth # noqa: I001 isort:skip
|
||||
from CTFd.forms import self # noqa: I001 isort:skip
|
||||
from CTFd.forms import teams # noqa: I001 isort:skip
|
||||
from CTFd.forms import setup # noqa: I001 isort:skip
|
||||
from CTFd.forms import submissions # noqa: I001 isort:skip
|
||||
from CTFd.forms import users # noqa: I001 isort:skip
|
||||
from CTFd.forms import challenges # noqa: I001 isort:skip
|
||||
from CTFd.forms import notifications # noqa: I001 isort:skip
|
||||
from CTFd.forms import config # noqa: I001 isort:skip
|
||||
from CTFd.forms import pages # noqa: I001 isort:skip
|
||||
from CTFd.forms import awards # noqa: I001 isort:skip
|
||||
from CTFd.forms import email # noqa: I001 isort:skip
|
||||
|
||||
Forms.auth = auth
|
||||
Forms.self = self
|
||||
Forms.teams = teams
|
||||
Forms.setup = setup
|
||||
Forms.submissions = submissions
|
||||
Forms.users = users
|
||||
Forms.challenges = challenges
|
||||
Forms.notifications = notifications
|
||||
Forms.config = config
|
||||
Forms.pages = pages
|
||||
Forms.awards = awards
|
||||
Forms.email = email
|
||||
33
CTFd/forms/auth.py
Normal file
33
CTFd/forms/auth.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from wtforms import PasswordField, StringField
|
||||
from wtforms.fields.html5 import EmailField
|
||||
from wtforms.validators import InputRequired
|
||||
|
||||
from CTFd.forms import BaseForm
|
||||
from CTFd.forms.fields import SubmitField
|
||||
|
||||
|
||||
class RegistrationForm(BaseForm):
|
||||
name = StringField("User Name", validators=[InputRequired()])
|
||||
email = EmailField("Email", validators=[InputRequired()])
|
||||
password = PasswordField("Password", validators=[InputRequired()])
|
||||
submit = SubmitField("Submit")
|
||||
|
||||
|
||||
class LoginForm(BaseForm):
|
||||
name = StringField("User Name or Email", validators=[InputRequired()])
|
||||
password = PasswordField("Password", validators=[InputRequired()])
|
||||
submit = SubmitField("Submit")
|
||||
|
||||
|
||||
class ConfirmForm(BaseForm):
|
||||
submit = SubmitField("Resend")
|
||||
|
||||
|
||||
class ResetPasswordRequestForm(BaseForm):
|
||||
email = EmailField("Email", validators=[InputRequired()])
|
||||
submit = SubmitField("Submit")
|
||||
|
||||
|
||||
class ResetPasswordForm(BaseForm):
|
||||
password = PasswordField("Password", validators=[InputRequired()])
|
||||
submit = SubmitField("Submit")
|
||||
30
CTFd/forms/awards.py
Normal file
30
CTFd/forms/awards.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from wtforms import RadioField, StringField, TextAreaField
|
||||
from wtforms.fields.html5 import IntegerField
|
||||
|
||||
from CTFd.forms import BaseForm
|
||||
from CTFd.forms.fields import SubmitField
|
||||
|
||||
|
||||
class AwardCreationForm(BaseForm):
|
||||
name = StringField("Name")
|
||||
value = IntegerField("Value")
|
||||
category = StringField("Category")
|
||||
description = TextAreaField("Description")
|
||||
submit = SubmitField("Create")
|
||||
icon = RadioField(
|
||||
"Icon",
|
||||
choices=[
|
||||
("", "None"),
|
||||
("shield", "Shield"),
|
||||
("bug", "Bug"),
|
||||
("crown", "Crown"),
|
||||
("crosshairs", "Crosshairs"),
|
||||
("ban", "Ban"),
|
||||
("lightning", "Lightning"),
|
||||
("skull", "Skull"),
|
||||
("brain", "Brain"),
|
||||
("code", "Code"),
|
||||
("cowboy", "Cowboy"),
|
||||
("angry", "Angry"),
|
||||
],
|
||||
)
|
||||
30
CTFd/forms/challenges.py
Normal file
30
CTFd/forms/challenges.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from wtforms import MultipleFileField, SelectField, StringField
|
||||
from wtforms.validators import InputRequired
|
||||
|
||||
from CTFd.forms import BaseForm
|
||||
from CTFd.forms.fields import SubmitField
|
||||
|
||||
|
||||
class ChallengeSearchForm(BaseForm):
|
||||
field = SelectField(
|
||||
"Search Field",
|
||||
choices=[
|
||||
("name", "Name"),
|
||||
("id", "ID"),
|
||||
("category", "Category"),
|
||||
("type", "Type"),
|
||||
],
|
||||
default="name",
|
||||
validators=[InputRequired()],
|
||||
)
|
||||
q = StringField("Parameter", validators=[InputRequired()])
|
||||
submit = SubmitField("Search")
|
||||
|
||||
|
||||
class ChallengeFilesUploadForm(BaseForm):
|
||||
file = MultipleFileField(
|
||||
"Upload Files",
|
||||
description="Attach multiple files using Control+Click or Cmd+Click.",
|
||||
validators=[InputRequired()],
|
||||
)
|
||||
submit = SubmitField("Upload")
|
||||
62
CTFd/forms/config.py
Normal file
62
CTFd/forms/config.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from wtforms import BooleanField, SelectField, StringField
|
||||
from wtforms.fields.html5 import IntegerField
|
||||
from wtforms.widgets.html5 import NumberInput
|
||||
|
||||
from CTFd.forms import BaseForm
|
||||
from CTFd.forms.fields import SubmitField
|
||||
from CTFd.models import db
|
||||
|
||||
|
||||
class ResetInstanceForm(BaseForm):
|
||||
accounts = BooleanField(
|
||||
"Accounts",
|
||||
description="Deletes all user and team accounts and their associated information",
|
||||
)
|
||||
submissions = BooleanField(
|
||||
"Submissions",
|
||||
description="Deletes all records that accounts gained points or took an action",
|
||||
)
|
||||
challenges = BooleanField(
|
||||
"Challenges", description="Deletes all challenges and associated data"
|
||||
)
|
||||
pages = BooleanField(
|
||||
"Pages", description="Deletes all pages and their associated files"
|
||||
)
|
||||
notifications = BooleanField(
|
||||
"Notifications", description="Deletes all notifications"
|
||||
)
|
||||
submit = SubmitField("Reset CTF")
|
||||
|
||||
|
||||
class AccountSettingsForm(BaseForm):
|
||||
domain_whitelist = StringField(
|
||||
"Account Email Whitelist",
|
||||
description="Comma-seperated email domains which users can register under (e.g. ctfd.io, gmail.com, yahoo.com)",
|
||||
)
|
||||
team_size = IntegerField(
|
||||
widget=NumberInput(min=0), description="Amount of users per team"
|
||||
)
|
||||
verify_emails = SelectField(
|
||||
"Verify Emails",
|
||||
description="Control whether users must confirm their email addresses before playing",
|
||||
choices=[("true", "Enabled"), ("false", "Disabled")],
|
||||
default="false",
|
||||
)
|
||||
name_changes = SelectField(
|
||||
"Name Changes",
|
||||
description="Control whether users can change their names",
|
||||
choices=[("true", "Enabled"), ("false", "Disabled")],
|
||||
default="true",
|
||||
)
|
||||
|
||||
submit = SubmitField("Update")
|
||||
|
||||
|
||||
class ExportCSVForm(BaseForm):
|
||||
table = SelectField(
|
||||
"Database Table",
|
||||
choices=list(
|
||||
zip(sorted(db.metadata.tables.keys()), sorted(db.metadata.tables.keys()))
|
||||
),
|
||||
)
|
||||
submit = SubmitField("Download CSV")
|
||||
10
CTFd/forms/email.py
Normal file
10
CTFd/forms/email.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from wtforms import TextAreaField
|
||||
from wtforms.validators import InputRequired
|
||||
|
||||
from CTFd.forms import BaseForm
|
||||
from CTFd.forms.fields import SubmitField
|
||||
|
||||
|
||||
class SendEmailForm(BaseForm):
|
||||
message = TextAreaField("Message", validators=[InputRequired()])
|
||||
submit = SubmitField("Send")
|
||||
17
CTFd/forms/fields.py
Normal file
17
CTFd/forms/fields.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from wtforms import SubmitField as _SubmitField
|
||||
|
||||
|
||||
class SubmitField(_SubmitField):
|
||||
"""
|
||||
This custom SubmitField exists because wtforms is dumb.
|
||||
|
||||
See https://github.com/wtforms/wtforms/issues/205, https://github.com/wtforms/wtforms/issues/36
|
||||
The .submit() handler in JS will break if the form has an input with the name or id of "submit" so submit fields need to be changed.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
name = kwargs.pop("name", "_submit")
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.name == "submit" or name:
|
||||
self.id = name
|
||||
self.name = name
|
||||
26
CTFd/forms/notifications.py
Normal file
26
CTFd/forms/notifications.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from wtforms import BooleanField, RadioField, StringField, TextAreaField
|
||||
from wtforms.validators import InputRequired
|
||||
|
||||
from CTFd.forms import BaseForm
|
||||
from CTFd.forms.fields import SubmitField
|
||||
|
||||
|
||||
class NotificationForm(BaseForm):
|
||||
title = StringField("Title", description="Notification title")
|
||||
content = TextAreaField(
|
||||
"Content",
|
||||
description="Notification contents. Can consist of HTML and/or Markdown.",
|
||||
)
|
||||
type = RadioField(
|
||||
"Notification Type",
|
||||
choices=[("toast", "Toast"), ("alert", "Alert"), ("background", "Background")],
|
||||
default="toast",
|
||||
description="What type of notification users receive",
|
||||
validators=[InputRequired()],
|
||||
)
|
||||
sound = BooleanField(
|
||||
"Play Sound",
|
||||
default=True,
|
||||
description="Play sound for users when they receive the notification",
|
||||
)
|
||||
submit = SubmitField("Submit")
|
||||
33
CTFd/forms/pages.py
Normal file
33
CTFd/forms/pages.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from wtforms import (
|
||||
BooleanField,
|
||||
HiddenField,
|
||||
MultipleFileField,
|
||||
StringField,
|
||||
TextAreaField,
|
||||
)
|
||||
from wtforms.validators import InputRequired
|
||||
|
||||
from CTFd.forms import BaseForm
|
||||
|
||||
|
||||
class PageEditForm(BaseForm):
|
||||
title = StringField(
|
||||
"Title", description="This is the title shown on the navigation bar"
|
||||
)
|
||||
route = StringField(
|
||||
"Route",
|
||||
description="This is the URL route that your page will be at (e.g. /page). You can also enter links to link to that page.",
|
||||
)
|
||||
draft = BooleanField("Draft")
|
||||
hidden = BooleanField("Hidden")
|
||||
auth_required = BooleanField("Authentication Required")
|
||||
content = TextAreaField("Content")
|
||||
|
||||
|
||||
class PageFilesUploadForm(BaseForm):
|
||||
file = MultipleFileField(
|
||||
"Upload Files",
|
||||
description="Attach multiple files using Control+Click or Cmd+Click.",
|
||||
validators=[InputRequired()],
|
||||
)
|
||||
type = HiddenField("Page Type", default="page", validators=[InputRequired()])
|
||||
22
CTFd/forms/self.py
Normal file
22
CTFd/forms/self.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from wtforms import PasswordField, SelectField, StringField
|
||||
from wtforms.fields.html5 import DateField, URLField
|
||||
|
||||
from CTFd.forms import BaseForm
|
||||
from CTFd.forms.fields import SubmitField
|
||||
from CTFd.utils.countries import SELECT_COUNTRIES_LIST
|
||||
|
||||
|
||||
class SettingsForm(BaseForm):
|
||||
name = StringField("User Name")
|
||||
email = StringField("Email")
|
||||
password = PasswordField("Password")
|
||||
confirm = PasswordField("Current Password")
|
||||
affiliation = StringField("Affiliation")
|
||||
website = URLField("Website")
|
||||
country = SelectField("Country", choices=SELECT_COUNTRIES_LIST)
|
||||
submit = SubmitField("Submit")
|
||||
|
||||
|
||||
class TokensForm(BaseForm):
|
||||
expiration = DateField("Expiration")
|
||||
submit = SubmitField("Generate")
|
||||
66
CTFd/forms/setup.py
Normal file
66
CTFd/forms/setup.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from wtforms import (
|
||||
HiddenField,
|
||||
PasswordField,
|
||||
RadioField,
|
||||
SelectField,
|
||||
StringField,
|
||||
TextAreaField,
|
||||
)
|
||||
from wtforms.fields.html5 import EmailField
|
||||
from wtforms.validators import InputRequired
|
||||
|
||||
from CTFd.forms import BaseForm
|
||||
from CTFd.forms.fields import SubmitField
|
||||
from CTFd.utils.config import get_themes
|
||||
|
||||
|
||||
class SetupForm(BaseForm):
|
||||
ctf_name = StringField(
|
||||
"Event Name", description="The name of your CTF event/workshop"
|
||||
)
|
||||
ctf_description = TextAreaField(
|
||||
"Event Description", description="Description for the CTF"
|
||||
)
|
||||
user_mode = RadioField(
|
||||
"User Mode",
|
||||
choices=[("teams", "Team Mode"), ("users", "User Mode")],
|
||||
default="teams",
|
||||
description="Controls whether users join together in teams to play (Team Mode) or play as themselves (User Mode)",
|
||||
validators=[InputRequired()],
|
||||
)
|
||||
|
||||
name = StringField(
|
||||
"Admin Username",
|
||||
description="Your username for the administration account",
|
||||
validators=[InputRequired()],
|
||||
)
|
||||
email = EmailField(
|
||||
"Admin Email",
|
||||
description="Your email address for the administration account",
|
||||
validators=[InputRequired()],
|
||||
)
|
||||
password = PasswordField(
|
||||
"Admin Password",
|
||||
description="Your password for the administration account",
|
||||
validators=[InputRequired()],
|
||||
)
|
||||
|
||||
ctf_theme = SelectField(
|
||||
"Theme",
|
||||
description="CTFd Theme to use",
|
||||
choices=list(zip(get_themes(), get_themes())),
|
||||
default="core",
|
||||
validators=[InputRequired()],
|
||||
)
|
||||
theme_color = HiddenField(
|
||||
"Theme Color",
|
||||
description="Color used by theme to control aesthetics. Requires theme support. Optional.",
|
||||
)
|
||||
|
||||
start = StringField(
|
||||
"Start Time", description="Time when your CTF is scheduled to start. Optional."
|
||||
)
|
||||
end = StringField(
|
||||
"End Time", description="Time when your CTF is scheduled to end. Optional."
|
||||
)
|
||||
submit = SubmitField("Finish")
|
||||
16
CTFd/forms/submissions.py
Normal file
16
CTFd/forms/submissions.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from wtforms import SelectField, StringField
|
||||
from wtforms.validators import InputRequired
|
||||
|
||||
from CTFd.forms import BaseForm
|
||||
from CTFd.forms.fields import SubmitField
|
||||
|
||||
|
||||
class SubmissionSearchForm(BaseForm):
|
||||
field = SelectField(
|
||||
"Search Field",
|
||||
choices=[("provided", "Provided"), ("id", "ID")],
|
||||
default="provided",
|
||||
validators=[InputRequired()],
|
||||
)
|
||||
q = StringField("Parameter", validators=[InputRequired()])
|
||||
submit = SubmitField("Search")
|
||||
82
CTFd/forms/teams.py
Normal file
82
CTFd/forms/teams.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from wtforms import BooleanField, PasswordField, SelectField, StringField
|
||||
from wtforms.fields.html5 import EmailField, URLField
|
||||
from wtforms.validators import InputRequired
|
||||
|
||||
from CTFd.forms import BaseForm
|
||||
from CTFd.forms.fields import SubmitField
|
||||
from CTFd.utils.countries import SELECT_COUNTRIES_LIST
|
||||
|
||||
|
||||
class TeamJoinForm(BaseForm):
|
||||
name = StringField("Team Name", validators=[InputRequired()])
|
||||
password = PasswordField("Team Password", validators=[InputRequired()])
|
||||
submit = SubmitField("Join")
|
||||
|
||||
|
||||
class TeamRegisterForm(BaseForm):
|
||||
name = StringField("Team Name", validators=[InputRequired()])
|
||||
password = PasswordField("Team Password", validators=[InputRequired()])
|
||||
submit = SubmitField("Create")
|
||||
|
||||
|
||||
class TeamSettingsForm(BaseForm):
|
||||
name = StringField("Team Name")
|
||||
confirm = PasswordField("Current Password")
|
||||
password = PasswordField("Team Password")
|
||||
affiliation = StringField("Affiliation")
|
||||
website = URLField("Website")
|
||||
country = SelectField("Country", choices=SELECT_COUNTRIES_LIST)
|
||||
submit = SubmitField("Submit")
|
||||
|
||||
|
||||
class TeamCaptainForm(BaseForm):
|
||||
# Choices are populated dynamically at form creation time
|
||||
captain_id = SelectField("Team Captain", choices=[], validators=[InputRequired()])
|
||||
submit = SubmitField("Submit")
|
||||
|
||||
|
||||
class TeamSearchForm(BaseForm):
|
||||
field = SelectField(
|
||||
"Search Field",
|
||||
choices=[
|
||||
("name", "Name"),
|
||||
("id", "ID"),
|
||||
("affiliation", "Affiliation"),
|
||||
("website", "Website"),
|
||||
],
|
||||
default="name",
|
||||
validators=[InputRequired()],
|
||||
)
|
||||
q = StringField("Parameter", validators=[InputRequired()])
|
||||
submit = SubmitField("Search")
|
||||
|
||||
|
||||
class PublicTeamSearchForm(BaseForm):
|
||||
field = SelectField(
|
||||
"Search Field",
|
||||
choices=[
|
||||
("name", "Name"),
|
||||
("affiliation", "Affiliation"),
|
||||
("website", "Website"),
|
||||
],
|
||||
default="name",
|
||||
validators=[InputRequired()],
|
||||
)
|
||||
q = StringField("Parameter", validators=[InputRequired()])
|
||||
submit = SubmitField("Search")
|
||||
|
||||
|
||||
class TeamCreateForm(BaseForm):
|
||||
name = StringField("Team Name", validators=[InputRequired()])
|
||||
email = EmailField("Email")
|
||||
password = PasswordField("Password")
|
||||
website = URLField("Website")
|
||||
affiliation = StringField("Affiliation")
|
||||
country = SelectField("Country", choices=SELECT_COUNTRIES_LIST)
|
||||
hidden = BooleanField("Hidden")
|
||||
banned = BooleanField("Banned")
|
||||
submit = SubmitField("Submit")
|
||||
|
||||
|
||||
class TeamEditForm(TeamCreateForm):
|
||||
pass
|
||||
58
CTFd/forms/users.py
Normal file
58
CTFd/forms/users.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from wtforms import BooleanField, PasswordField, SelectField, StringField
|
||||
from wtforms.fields.html5 import EmailField
|
||||
from wtforms.validators import InputRequired
|
||||
|
||||
from CTFd.forms import BaseForm
|
||||
from CTFd.forms.fields import SubmitField
|
||||
from CTFd.utils.countries import SELECT_COUNTRIES_LIST
|
||||
|
||||
|
||||
class UserSearchForm(BaseForm):
|
||||
field = SelectField(
|
||||
"Search Field",
|
||||
choices=[
|
||||
("name", "Name"),
|
||||
("id", "ID"),
|
||||
("email", "Email"),
|
||||
("affiliation", "Affiliation"),
|
||||
("website", "Website"),
|
||||
("ip", "IP Address"),
|
||||
],
|
||||
default="name",
|
||||
validators=[InputRequired()],
|
||||
)
|
||||
q = StringField("Parameter", validators=[InputRequired()])
|
||||
submit = SubmitField("Search")
|
||||
|
||||
|
||||
class PublicUserSearchForm(BaseForm):
|
||||
field = SelectField(
|
||||
"Search Field",
|
||||
choices=[
|
||||
("name", "Name"),
|
||||
("affiliation", "Affiliation"),
|
||||
("website", "Website"),
|
||||
],
|
||||
default="name",
|
||||
validators=[InputRequired()],
|
||||
)
|
||||
q = StringField("Parameter", validators=[InputRequired()])
|
||||
submit = SubmitField("Search")
|
||||
|
||||
|
||||
class UserEditForm(BaseForm):
|
||||
name = StringField("User Name", validators=[InputRequired()])
|
||||
email = EmailField("Email", validators=[InputRequired()])
|
||||
password = PasswordField("Password")
|
||||
website = StringField("Website")
|
||||
affiliation = StringField("Affiliation")
|
||||
country = SelectField("Country", choices=SELECT_COUNTRIES_LIST)
|
||||
type = SelectField("Type", choices=[("user", "User"), ("admin", "Admin")])
|
||||
verified = BooleanField("Verified")
|
||||
hidden = BooleanField("Hidden")
|
||||
banned = BooleanField("Banned")
|
||||
submit = SubmitField("Submit")
|
||||
|
||||
|
||||
class UserCreateForm(UserEditForm):
|
||||
notify = BooleanField("Email account credentials to user", default=True)
|
||||
@@ -6,8 +6,6 @@ from sqlalchemy.ext.hybrid import hybrid_property
|
||||
from sqlalchemy.orm import column_property, validates
|
||||
|
||||
from CTFd.cache import cache
|
||||
from CTFd.utils.crypto import hash_password
|
||||
from CTFd.utils.humanize.numbers import ordinalize
|
||||
|
||||
db = SQLAlchemy()
|
||||
ma = Marshmallow()
|
||||
@@ -81,6 +79,13 @@ class Challenges(db.Model):
|
||||
|
||||
__mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type}
|
||||
|
||||
@property
|
||||
def html(self):
|
||||
from CTFd.utils.config.pages import build_html
|
||||
from CTFd.utils.helpers import markup
|
||||
|
||||
return markup(build_html(self.description))
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(Challenges, self).__init__(**kwargs)
|
||||
|
||||
@@ -256,6 +261,8 @@ class Users(db.Model):
|
||||
|
||||
@validates("password")
|
||||
def validate_password(self, key, plaintext):
|
||||
from CTFd.utils.crypto import hash_password
|
||||
|
||||
return hash_password(str(plaintext))
|
||||
|
||||
@hybrid_property
|
||||
@@ -268,6 +275,16 @@ class Users(db.Model):
|
||||
elif user_mode == "users":
|
||||
return self.id
|
||||
|
||||
@hybrid_property
|
||||
def account(self):
|
||||
from CTFd.utils import get_config
|
||||
|
||||
user_mode = get_config("user_mode")
|
||||
if user_mode == "teams":
|
||||
return self.team
|
||||
elif user_mode == "users":
|
||||
return self
|
||||
|
||||
@property
|
||||
def solves(self):
|
||||
return self.get_solves(admin=False)
|
||||
@@ -365,6 +382,7 @@ class Users(db.Model):
|
||||
application itself will result in a circular import.
|
||||
"""
|
||||
from CTFd.utils.scores import get_user_standings
|
||||
from CTFd.utils.humanize.numbers import ordinalize
|
||||
|
||||
standings = get_user_standings(admin=admin)
|
||||
|
||||
@@ -418,6 +436,8 @@ class Teams(db.Model):
|
||||
|
||||
@validates("password")
|
||||
def validate_password(self, key, plaintext):
|
||||
from CTFd.utils.crypto import hash_password
|
||||
|
||||
return hash_password(str(plaintext))
|
||||
|
||||
@property
|
||||
@@ -509,6 +529,7 @@ class Teams(db.Model):
|
||||
application itself will result in a circular import.
|
||||
"""
|
||||
from CTFd.utils.scores import get_team_standings
|
||||
from CTFd.utils.humanize.numbers import ordinalize
|
||||
|
||||
standings = get_team_standings(admin=admin)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import os
|
||||
from collections import namedtuple
|
||||
|
||||
from flask import current_app as app
|
||||
from flask import send_file, send_from_directory
|
||||
from flask import send_file, send_from_directory, url_for
|
||||
|
||||
from CTFd.utils.config.pages import get_pages
|
||||
from CTFd.utils.decorators import admins_only as admins_only_wrapper
|
||||
@@ -114,6 +114,9 @@ def register_admin_plugin_menu_bar(title, route):
|
||||
:param route: A string that is the href used by the link
|
||||
:return:
|
||||
"""
|
||||
if (route.startswith("http://") or route.startswith("https://")) is False:
|
||||
route = url_for("views.static_html", route=route)
|
||||
|
||||
am = Menu(title=title, route=route)
|
||||
app.admin_plugin_menu_bar.append(am)
|
||||
|
||||
@@ -135,6 +138,9 @@ def register_user_page_menu_bar(title, route):
|
||||
:param route: A string that is the href used by the link
|
||||
:return:
|
||||
"""
|
||||
if (route.startswith("http://") or route.startswith("https://")) is False:
|
||||
route = url_for("views.static_html", route=route)
|
||||
|
||||
p = Menu(title=title, route=route)
|
||||
app.plugin_menu_bar.append(p)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from CTFd.models import (
|
||||
db,
|
||||
)
|
||||
from CTFd.plugins import register_plugin_assets_directory
|
||||
from CTFd.plugins.flags import get_flag_class
|
||||
from CTFd.plugins.flags import FlagException, get_flag_class
|
||||
from CTFd.utils.uploads import delete_file
|
||||
from CTFd.utils.user import get_ip
|
||||
|
||||
@@ -21,6 +21,153 @@ class BaseChallenge(object):
|
||||
name = None
|
||||
templates = {}
|
||||
scripts = {}
|
||||
challenge_model = Challenges
|
||||
|
||||
@classmethod
|
||||
def create(cls, request):
|
||||
"""
|
||||
This method is used to process the challenge creation request.
|
||||
|
||||
:param request:
|
||||
:return:
|
||||
"""
|
||||
data = request.form or request.get_json()
|
||||
|
||||
challenge = cls.challenge_model(**data)
|
||||
|
||||
db.session.add(challenge)
|
||||
db.session.commit()
|
||||
|
||||
return challenge
|
||||
|
||||
@classmethod
|
||||
def read(cls, challenge):
|
||||
"""
|
||||
This method is in used to access the data of a challenge in a format processable by the front end.
|
||||
|
||||
:param challenge:
|
||||
:return: Challenge object, data dictionary to be returned to the user
|
||||
"""
|
||||
data = {
|
||||
"id": challenge.id,
|
||||
"name": challenge.name,
|
||||
"value": challenge.value,
|
||||
"description": challenge.description,
|
||||
"category": challenge.category,
|
||||
"state": challenge.state,
|
||||
"max_attempts": challenge.max_attempts,
|
||||
"type": challenge.type,
|
||||
"type_data": {
|
||||
"id": cls.id,
|
||||
"name": cls.name,
|
||||
"templates": cls.templates,
|
||||
"scripts": cls.scripts,
|
||||
},
|
||||
}
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def update(cls, challenge, request):
|
||||
"""
|
||||
This method is used to update the information associated with a challenge. This should be kept strictly to the
|
||||
Challenges table and any child tables.
|
||||
|
||||
:param challenge:
|
||||
:param request:
|
||||
:return:
|
||||
"""
|
||||
data = request.form or request.get_json()
|
||||
for attr, value in data.items():
|
||||
setattr(challenge, attr, value)
|
||||
|
||||
db.session.commit()
|
||||
return challenge
|
||||
|
||||
@classmethod
|
||||
def delete(cls, challenge):
|
||||
"""
|
||||
This method is used to delete the resources used by a challenge.
|
||||
|
||||
:param challenge:
|
||||
:return:
|
||||
"""
|
||||
Fails.query.filter_by(challenge_id=challenge.id).delete()
|
||||
Solves.query.filter_by(challenge_id=challenge.id).delete()
|
||||
Flags.query.filter_by(challenge_id=challenge.id).delete()
|
||||
files = ChallengeFiles.query.filter_by(challenge_id=challenge.id).all()
|
||||
for f in files:
|
||||
delete_file(f.id)
|
||||
ChallengeFiles.query.filter_by(challenge_id=challenge.id).delete()
|
||||
Tags.query.filter_by(challenge_id=challenge.id).delete()
|
||||
Hints.query.filter_by(challenge_id=challenge.id).delete()
|
||||
cls.challenge_model.query.filter_by(id=challenge.id).delete()
|
||||
db.session.commit()
|
||||
|
||||
@classmethod
|
||||
def attempt(cls, challenge, request):
|
||||
"""
|
||||
This method is used to check whether a given input is right or wrong. It does not make any changes and should
|
||||
return a boolean for correctness and a string to be shown to the user. It is also in charge of parsing the
|
||||
user's input from the request itself.
|
||||
|
||||
:param challenge: The Challenge object from the database
|
||||
:param request: The request the user submitted
|
||||
:return: (boolean, string)
|
||||
"""
|
||||
data = request.form or request.get_json()
|
||||
submission = data["submission"].strip()
|
||||
flags = Flags.query.filter_by(challenge_id=challenge.id).all()
|
||||
for flag in flags:
|
||||
try:
|
||||
if get_flag_class(flag.type).compare(flag, submission):
|
||||
return True, "Correct"
|
||||
except FlagException as e:
|
||||
return False, e.message
|
||||
return False, "Incorrect"
|
||||
|
||||
@classmethod
|
||||
def solve(cls, user, team, challenge, request):
|
||||
"""
|
||||
This method is used to insert Solves into the database in order to mark a challenge as solved.
|
||||
|
||||
:param team: The Team object from the database
|
||||
:param chal: The Challenge object from the database
|
||||
:param request: The request the user submitted
|
||||
:return:
|
||||
"""
|
||||
data = request.form or request.get_json()
|
||||
submission = data["submission"].strip()
|
||||
solve = Solves(
|
||||
user_id=user.id,
|
||||
team_id=team.id if team else None,
|
||||
challenge_id=challenge.id,
|
||||
ip=get_ip(req=request),
|
||||
provided=submission,
|
||||
)
|
||||
db.session.add(solve)
|
||||
db.session.commit()
|
||||
|
||||
@classmethod
|
||||
def fail(cls, user, team, challenge, request):
|
||||
"""
|
||||
This method is used to insert Fails into the database in order to mark an answer incorrect.
|
||||
|
||||
:param team: The Team object from the database
|
||||
:param chal: The Challenge object from the database
|
||||
:param request: The request the user submitted
|
||||
:return:
|
||||
"""
|
||||
data = request.form or request.get_json()
|
||||
submission = data["submission"].strip()
|
||||
wrong = Fails(
|
||||
user_id=user.id,
|
||||
team_id=team.id if team else None,
|
||||
challenge_id=challenge.id,
|
||||
ip=get_ip(request),
|
||||
provided=submission,
|
||||
)
|
||||
db.session.add(wrong)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
class CTFdStandardChallenge(BaseChallenge):
|
||||
@@ -42,151 +189,7 @@ class CTFdStandardChallenge(BaseChallenge):
|
||||
blueprint = Blueprint(
|
||||
"standard", __name__, template_folder="templates", static_folder="assets"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create(request):
|
||||
"""
|
||||
This method is used to process the challenge creation request.
|
||||
|
||||
:param request:
|
||||
:return:
|
||||
"""
|
||||
data = request.form or request.get_json()
|
||||
|
||||
challenge = Challenges(**data)
|
||||
|
||||
db.session.add(challenge)
|
||||
db.session.commit()
|
||||
|
||||
return challenge
|
||||
|
||||
@staticmethod
|
||||
def read(challenge):
|
||||
"""
|
||||
This method is in used to access the data of a challenge in a format processable by the front end.
|
||||
|
||||
:param challenge:
|
||||
:return: Challenge object, data dictionary to be returned to the user
|
||||
"""
|
||||
data = {
|
||||
"id": challenge.id,
|
||||
"name": challenge.name,
|
||||
"value": challenge.value,
|
||||
"description": challenge.description,
|
||||
"category": challenge.category,
|
||||
"state": challenge.state,
|
||||
"max_attempts": challenge.max_attempts,
|
||||
"type": challenge.type,
|
||||
"type_data": {
|
||||
"id": CTFdStandardChallenge.id,
|
||||
"name": CTFdStandardChallenge.name,
|
||||
"templates": CTFdStandardChallenge.templates,
|
||||
"scripts": CTFdStandardChallenge.scripts,
|
||||
},
|
||||
}
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def update(challenge, request):
|
||||
"""
|
||||
This method is used to update the information associated with a challenge. This should be kept strictly to the
|
||||
Challenges table and any child tables.
|
||||
|
||||
:param challenge:
|
||||
:param request:
|
||||
:return:
|
||||
"""
|
||||
data = request.form or request.get_json()
|
||||
for attr, value in data.items():
|
||||
setattr(challenge, attr, value)
|
||||
|
||||
db.session.commit()
|
||||
return challenge
|
||||
|
||||
@staticmethod
|
||||
def delete(challenge):
|
||||
"""
|
||||
This method is used to delete the resources used by a challenge.
|
||||
|
||||
:param challenge:
|
||||
:return:
|
||||
"""
|
||||
Fails.query.filter_by(challenge_id=challenge.id).delete()
|
||||
Solves.query.filter_by(challenge_id=challenge.id).delete()
|
||||
Flags.query.filter_by(challenge_id=challenge.id).delete()
|
||||
files = ChallengeFiles.query.filter_by(challenge_id=challenge.id).all()
|
||||
for f in files:
|
||||
delete_file(f.id)
|
||||
ChallengeFiles.query.filter_by(challenge_id=challenge.id).delete()
|
||||
Tags.query.filter_by(challenge_id=challenge.id).delete()
|
||||
Hints.query.filter_by(challenge_id=challenge.id).delete()
|
||||
Challenges.query.filter_by(id=challenge.id).delete()
|
||||
db.session.commit()
|
||||
|
||||
@staticmethod
|
||||
def attempt(challenge, request):
|
||||
"""
|
||||
This method is used to check whether a given input is right or wrong. It does not make any changes and should
|
||||
return a boolean for correctness and a string to be shown to the user. It is also in charge of parsing the
|
||||
user's input from the request itself.
|
||||
|
||||
:param challenge: The Challenge object from the database
|
||||
:param request: The request the user submitted
|
||||
:return: (boolean, string)
|
||||
"""
|
||||
data = request.form or request.get_json()
|
||||
submission = data["submission"].strip()
|
||||
flags = Flags.query.filter_by(challenge_id=challenge.id).all()
|
||||
for flag in flags:
|
||||
if get_flag_class(flag.type).compare(flag, submission):
|
||||
return True, "Correct"
|
||||
return False, "Incorrect"
|
||||
|
||||
@staticmethod
|
||||
def solve(user, team, challenge, request):
|
||||
"""
|
||||
This method is used to insert Solves into the database in order to mark a challenge as solved.
|
||||
|
||||
:param team: The Team object from the database
|
||||
:param chal: The Challenge object from the database
|
||||
:param request: The request the user submitted
|
||||
:return:
|
||||
"""
|
||||
data = request.form or request.get_json()
|
||||
submission = data["submission"].strip()
|
||||
solve = Solves(
|
||||
user_id=user.id,
|
||||
team_id=team.id if team else None,
|
||||
challenge_id=challenge.id,
|
||||
ip=get_ip(req=request),
|
||||
provided=submission,
|
||||
)
|
||||
db.session.add(solve)
|
||||
db.session.commit()
|
||||
db.session.close()
|
||||
|
||||
@staticmethod
|
||||
def fail(user, team, challenge, request):
|
||||
"""
|
||||
This method is used to insert Fails into the database in order to mark an answer incorrect.
|
||||
|
||||
:param team: The Team object from the database
|
||||
:param chal: The Challenge object from the database
|
||||
:param request: The request the user submitted
|
||||
:return:
|
||||
"""
|
||||
data = request.form or request.get_json()
|
||||
submission = data["submission"].strip()
|
||||
wrong = Fails(
|
||||
user_id=user.id,
|
||||
team_id=team.id if team else None,
|
||||
challenge_id=challenge.id,
|
||||
ip=get_ip(request),
|
||||
provided=submission,
|
||||
)
|
||||
db.session.add(wrong)
|
||||
db.session.commit()
|
||||
db.session.close()
|
||||
challenge_model = Challenges
|
||||
|
||||
|
||||
def get_chal_class(class_id):
|
||||
|
||||
@@ -1,64 +1 @@
|
||||
<form method="POST" action="{{ script_root }}/admin/challenges/new" enctype="multipart/form-data">
|
||||
<div class="form-group">
|
||||
<label>
|
||||
Name:<br>
|
||||
<small class="form-text text-muted">
|
||||
The name of your challenge
|
||||
</small>
|
||||
</label>
|
||||
<input type="text" class="form-control" name="name" placeholder="Enter challenge name">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
Category:<br>
|
||||
<small class="form-text text-muted">
|
||||
The category of your challenge
|
||||
</small>
|
||||
</label>
|
||||
<input type="text" class="form-control" name="category" placeholder="Enter challenge category">
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs" role="tablist" id="new-desc-edit">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" href="#new-desc-write" aria-controls="home" role="tab"
|
||||
data-toggle="tab" tabindex="-1">Write</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#new-desc-preview" aria-controls="home" role="tab" data-toggle="tab" tabindex="-1">Preview</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<div role="tabpanel" class="tab-pane active" id="new-desc-write">
|
||||
<div class="form-group">
|
||||
<label>
|
||||
Message:<br>
|
||||
<small class="form-text text-muted">
|
||||
Use this to give a brief introduction to your challenge.
|
||||
</small>
|
||||
</label>
|
||||
<textarea id="new-desc-editor" class="form-control" name="description" rows="10"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane content" id="new-desc-preview" style="height:234px; overflow-y: scroll;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
Value:<br>
|
||||
<small class="form-text text-muted">
|
||||
This is how many points are rewarded for solving this challenge.
|
||||
</small>
|
||||
</label>
|
||||
<input type="number" class="form-control" name="value" placeholder="Enter value" required>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="state" value="hidden">
|
||||
<input type="hidden" name="type" value="standard">
|
||||
|
||||
<div class="form-group">
|
||||
<button class="btn btn-primary float-right create-challenge-submit" type="submit">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
{% extends "admin/challenges/create.html" %}
|
||||
@@ -1,39 +1,4 @@
|
||||
CTFd.plugin.run((_CTFd) => {
|
||||
const $ = _CTFd.lib.$
|
||||
const md = _CTFd.lib.markdown()
|
||||
$('a[href="#new-desc-preview"]').on('shown.bs.tab', function (event) {
|
||||
if (event.target.hash == '#new-desc-preview') {
|
||||
var editor_value = $('#new-desc-editor').val();
|
||||
$(event.target.hash).html(
|
||||
md.render(editor_value)
|
||||
);
|
||||
}
|
||||
});
|
||||
// $('#desc-edit').on('shown.bs.tab', function (event) {
|
||||
// if (event.target.hash == '#desc-preview') {
|
||||
// var editor_value = $('#desc-editor').val();
|
||||
// $(event.target.hash).html(
|
||||
// window.challenge.render(editor_value)
|
||||
// );
|
||||
// }
|
||||
// });
|
||||
// $('#new-desc-edit').on('shown.bs.tab', function (event) {
|
||||
// if (event.target.hash == '#new-desc-preview') {
|
||||
// var editor_value = $('#new-desc-editor').val();
|
||||
// $(event.target.hash).html(
|
||||
// window.challenge.render(editor_value)
|
||||
// );
|
||||
// }
|
||||
// });
|
||||
// $("#solve-attempts-checkbox").change(function () {
|
||||
// if (this.checked) {
|
||||
// $('#solve-attempts-input').show();
|
||||
// } else {
|
||||
// $('#solve-attempts-input').hide();
|
||||
// $('#max_attempts').val('');
|
||||
// }
|
||||
// });
|
||||
// $(document).ready(function () {
|
||||
// $('[data-toggle="tooltip"]').tooltip();
|
||||
// });
|
||||
})
|
||||
|
||||
@@ -1,64 +1 @@
|
||||
<form method="POST">
|
||||
<div class="form-group">
|
||||
<label>
|
||||
Name<br>
|
||||
<small class="form-text text-muted">Challenge Name</small>
|
||||
</label>
|
||||
<input type="text" class="form-control chal-name" name="name" value="{{ challenge.name }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
Category<br>
|
||||
<small class="form-text text-muted">Challenge Category</small>
|
||||
</label>
|
||||
<input type="text" class="form-control chal-category" name="category" value="{{ challenge.category }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
Message<br>
|
||||
<small class="form-text text-muted">
|
||||
Use this to give a brief introduction to your challenge.
|
||||
</small>
|
||||
</label>
|
||||
<textarea id="desc-editor" class="form-control chal-desc-editor" name="description" rows="10">{{ challenge.description }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="value">
|
||||
Value<br>
|
||||
<small class="form-text text-muted">
|
||||
This is how many points teams will receive once they solve this challenge.
|
||||
</small>
|
||||
</label>
|
||||
<input type="number" class="form-control chal-value" name="value" value="{{ challenge.value }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
Max Attempts<br>
|
||||
<small class="form-text text-muted">Maximum amount of attempts users receive. Leave at 0 for unlimited.</small>
|
||||
</label>
|
||||
|
||||
<input type="number" class="form-control chal-attempts" name="max_attempts" value="{{ challenge.max_attempts }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
State<br>
|
||||
<small class="form-text text-muted">Changes the state of the challenge (e.g. visible, hidden)</small>
|
||||
</label>
|
||||
|
||||
<select class="form-control custom-select" name="state">
|
||||
<option value="visible" {% if challenge.state == "visible" %}selected{% endif %}>Visible</option>
|
||||
<option value="hidden" {% if challenge.state == "hidden" %}selected{% endif %}>Hidden</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button class="btn btn-success btn-outlined float-right" type="submit">
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% extends "admin/challenges/update.html" %}
|
||||
@@ -1,117 +1,16 @@
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-body">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
<ul class="nav nav-tabs">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" href="#challenge">Challenge</a>
|
||||
</li>
|
||||
{% if solves == None %}
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link challenge-solves" href="#solves">
|
||||
{{ solves }} {% if solves > 1 %}Solves{% else %}Solves{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<div role="tabpanel">
|
||||
<div class="tab-content">
|
||||
<div role="tabpanel" class="tab-pane fade show active" id="challenge">
|
||||
<h2 class='challenge-name text-center pt-3'>{{ name }}</h2>
|
||||
<h3 class="challenge-value text-center">{{ value }}</h3>
|
||||
<div class="challenge-tags text-center">
|
||||
{% for tag in tags %}
|
||||
<span class='badge badge-info challenge-tag'>{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<span class="challenge-desc">{{ description | safe }}</span>
|
||||
<div class="challenge-hints hint-row row">
|
||||
{% for hint in hints %}
|
||||
<div class='col-md-12 hint-button-wrapper text-center mb-3'>
|
||||
<a class="btn btn-info btn-hint btn-block load-hint" href="javascript:;" data-hint-id="{{ hint.id }}">
|
||||
{% if hint.content %}
|
||||
<small>
|
||||
View Hint
|
||||
</small>
|
||||
{% else %}
|
||||
{% if hint.cost %}
|
||||
<small>
|
||||
Unlock Hint for {{ hint.cost }} points
|
||||
</small>
|
||||
{% else %}
|
||||
<small>
|
||||
View Hint
|
||||
</small>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="row challenge-files text-center pb-3">
|
||||
{% for file in files %}
|
||||
<div class='col-md-4 col-sm-4 col-xs-12 file-button-wrapper d-block'>
|
||||
<a class='btn btn-info btn-file mb-1 d-inline-block px-2 w-100 text-truncate'
|
||||
href='{{ file }}'>
|
||||
<i class="fas fa-download"></i>
|
||||
<small>
|
||||
{% set segments = file.split('/') %}
|
||||
{% set file = segments | last %}
|
||||
{% set token = file.split('?') | last %}
|
||||
{% if token %}
|
||||
{{ file | replace("?" + token, "") }}
|
||||
{% else %}
|
||||
{{ file }}
|
||||
{% endif %}
|
||||
</small>
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% extends "challenge.html" %}
|
||||
|
||||
<div class="row submit-row">
|
||||
<div class="col-md-9 form-group">
|
||||
<input class="form-control" type="text" name="answer" id="submission-input" placeholder="Flag"/>
|
||||
<input id="challenge-id" type="hidden" value="{{ id }}">
|
||||
</div>
|
||||
<div class="col-md-3 form-group key-submit">
|
||||
<button type="submit" id="submit-key" tabindex="0"
|
||||
class="btn btn-md btn-outline-secondary float-right">Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row notification-row">
|
||||
<div class="col-md-12">
|
||||
<div id="result-notification" class="alert alert-dismissable text-center w-100"
|
||||
role="alert" style="display: none;">
|
||||
<strong id="result-message"></strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane fade" id="solves">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<table class="table table-striped text-center">
|
||||
<thead>
|
||||
<tr>
|
||||
<td><b>Name</b>
|
||||
</td>
|
||||
<td><b>Date</b>
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="challenge-solves-names">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% block description %}
|
||||
{{ challenge.html }}
|
||||
{% endblock %}
|
||||
|
||||
{% block input %}
|
||||
<input id="challenge-id" class="challenge-id" type="hidden" value="{{ challenge.id }}">
|
||||
<input id="challenge-input" class="challenge-input" type="text" name="answer" placeholder="Flag"/>
|
||||
{% endblock %}
|
||||
|
||||
{% block submit %}
|
||||
<button id="challenge-submit" class="challenge-submit" type="submit">
|
||||
Submit
|
||||
</button>
|
||||
{% endblock %}
|
||||
@@ -15,7 +15,7 @@ CTFd._internal.challenge.postRender = function () { }
|
||||
|
||||
CTFd._internal.challenge.submit = function (preview) {
|
||||
var challenge_id = parseInt(CTFd.lib.$('#challenge-id').val())
|
||||
var submission = CTFd.lib.$('#submission-input').val()
|
||||
var submission = CTFd.lib.$('#challenge-input').val()
|
||||
|
||||
var body = {
|
||||
'challenge_id': challenge_id,
|
||||
|
||||
@@ -7,18 +7,18 @@ This CTFd plugin creates a dynamic challenge type which implements this
|
||||
behavior. Each dynamic challenge starts with an initial point value and then
|
||||
each solve will decrease the value of the challenge until a minimum point value.
|
||||
|
||||
By reducing the value of the challenge on each solve, all users who have previously
|
||||
solved the challenge will have lowered scores. Thus an easier and more solved
|
||||
challenge will naturally have a lower point value than a harder and less solved
|
||||
challenge.
|
||||
By reducing the value of the challenge on each solve, all users who have previously
|
||||
solved the challenge will have lowered scores. Thus an easier and more solved
|
||||
challenge will naturally have a lower point value than a harder and less solved
|
||||
challenge.
|
||||
|
||||
Within CTFd you are free to mix and match regular and dynamic challenges.
|
||||
|
||||
The current implementation requires the challenge to keep track of three values:
|
||||
|
||||
* Initial - The original point valuation
|
||||
* Decay - The amount of solves before the challenge will be at the minimum
|
||||
* Minimum - The lowest possible point valuation
|
||||
- Initial - The original point valuation
|
||||
- Decay - The amount of solves before the challenge will be at the minimum
|
||||
- Minimum - The lowest possible point valuation
|
||||
|
||||
The value decay logic is implemented with the following math:
|
||||
|
||||
@@ -43,12 +43,12 @@ If the number generated is lower than the minimum, the minimum is chosen
|
||||
instead.
|
||||
|
||||
A parabolic function is chosen instead of an exponential or logarithmic decay function
|
||||
so that higher valued challenges have a slower drop from their initial value.
|
||||
so that higher valued challenges have a slower drop from their initial value.
|
||||
|
||||
# Installation
|
||||
|
||||
**REQUIRES: CTFd >= v1.2.0**
|
||||
|
||||
1. Clone this repository to `CTFd/plugins`. It is important that the folder is
|
||||
named `DynamicValueChallenge` so CTFd can serve the files in the `assets`
|
||||
directory.
|
||||
named `DynamicValueChallenge` so CTFd can serve the files in the `assets`
|
||||
directory.
|
||||
|
||||
@@ -4,23 +4,25 @@ import math
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
from CTFd.models import (
|
||||
ChallengeFiles,
|
||||
Challenges,
|
||||
Fails,
|
||||
Flags,
|
||||
Hints,
|
||||
Solves,
|
||||
Tags,
|
||||
db,
|
||||
)
|
||||
from CTFd.models import Challenges, Solves, db
|
||||
from CTFd.plugins import register_plugin_assets_directory
|
||||
from CTFd.plugins.migrations import upgrade
|
||||
from CTFd.plugins.challenges import CHALLENGE_CLASSES, BaseChallenge
|
||||
from CTFd.plugins.flags import get_flag_class
|
||||
from CTFd.plugins.migrations import upgrade
|
||||
from CTFd.utils.modes import get_model
|
||||
from CTFd.utils.uploads import delete_file
|
||||
from CTFd.utils.user import get_ip
|
||||
|
||||
|
||||
class DynamicChallenge(Challenges):
|
||||
__mapper_args__ = {"polymorphic_identity": "dynamic"}
|
||||
id = db.Column(
|
||||
db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
initial = db.Column(db.Integer, default=0)
|
||||
minimum = db.Column(db.Integer, default=0)
|
||||
decay = db.Column(db.Integer, default=0)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(DynamicChallenge, self).__init__(**kwargs)
|
||||
self.initial = kwargs["value"]
|
||||
|
||||
|
||||
class DynamicValueChallenge(BaseChallenge):
|
||||
@@ -45,6 +47,7 @@ class DynamicValueChallenge(BaseChallenge):
|
||||
template_folder="templates",
|
||||
static_folder="assets",
|
||||
)
|
||||
challenge_model = DynamicChallenge
|
||||
|
||||
@classmethod
|
||||
def calculate_value(cls, challenge):
|
||||
@@ -82,24 +85,8 @@ class DynamicValueChallenge(BaseChallenge):
|
||||
db.session.commit()
|
||||
return challenge
|
||||
|
||||
@staticmethod
|
||||
def create(request):
|
||||
"""
|
||||
This method is used to process the challenge creation request.
|
||||
|
||||
:param request:
|
||||
:return:
|
||||
"""
|
||||
data = request.form or request.get_json()
|
||||
challenge = DynamicChallenge(**data)
|
||||
|
||||
db.session.add(challenge)
|
||||
db.session.commit()
|
||||
|
||||
return challenge
|
||||
|
||||
@staticmethod
|
||||
def read(challenge):
|
||||
@classmethod
|
||||
def read(cls, challenge):
|
||||
"""
|
||||
This method is in used to access the data of a challenge in a format processable by the front end.
|
||||
|
||||
@@ -120,16 +107,16 @@ class DynamicValueChallenge(BaseChallenge):
|
||||
"max_attempts": challenge.max_attempts,
|
||||
"type": challenge.type,
|
||||
"type_data": {
|
||||
"id": DynamicValueChallenge.id,
|
||||
"name": DynamicValueChallenge.name,
|
||||
"templates": DynamicValueChallenge.templates,
|
||||
"scripts": DynamicValueChallenge.scripts,
|
||||
"id": cls.id,
|
||||
"name": cls.name,
|
||||
"templates": cls.templates,
|
||||
"scripts": cls.scripts,
|
||||
},
|
||||
}
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def update(challenge, request):
|
||||
@classmethod
|
||||
def update(cls, challenge, request):
|
||||
"""
|
||||
This method is used to update the information associated with a challenge. This should be kept strictly to the
|
||||
Challenges table and any child tables.
|
||||
@@ -148,109 +135,12 @@ class DynamicValueChallenge(BaseChallenge):
|
||||
|
||||
return DynamicValueChallenge.calculate_value(challenge)
|
||||
|
||||
@staticmethod
|
||||
def delete(challenge):
|
||||
"""
|
||||
This method is used to delete the resources used by a challenge.
|
||||
|
||||
:param challenge:
|
||||
:return:
|
||||
"""
|
||||
Fails.query.filter_by(challenge_id=challenge.id).delete()
|
||||
Solves.query.filter_by(challenge_id=challenge.id).delete()
|
||||
Flags.query.filter_by(challenge_id=challenge.id).delete()
|
||||
files = ChallengeFiles.query.filter_by(challenge_id=challenge.id).all()
|
||||
for f in files:
|
||||
delete_file(f.id)
|
||||
ChallengeFiles.query.filter_by(challenge_id=challenge.id).delete()
|
||||
Tags.query.filter_by(challenge_id=challenge.id).delete()
|
||||
Hints.query.filter_by(challenge_id=challenge.id).delete()
|
||||
DynamicChallenge.query.filter_by(id=challenge.id).delete()
|
||||
Challenges.query.filter_by(id=challenge.id).delete()
|
||||
db.session.commit()
|
||||
|
||||
@staticmethod
|
||||
def attempt(challenge, request):
|
||||
"""
|
||||
This method is used to check whether a given input is right or wrong. It does not make any changes and should
|
||||
return a boolean for correctness and a string to be shown to the user. It is also in charge of parsing the
|
||||
user's input from the request itself.
|
||||
|
||||
:param challenge: The Challenge object from the database
|
||||
:param request: The request the user submitted
|
||||
:return: (boolean, string)
|
||||
"""
|
||||
data = request.form or request.get_json()
|
||||
submission = data["submission"].strip()
|
||||
flags = Flags.query.filter_by(challenge_id=challenge.id).all()
|
||||
for flag in flags:
|
||||
if get_flag_class(flag.type).compare(flag, submission):
|
||||
return True, "Correct"
|
||||
return False, "Incorrect"
|
||||
|
||||
@staticmethod
|
||||
def solve(user, team, challenge, request):
|
||||
"""
|
||||
This method is used to insert Solves into the database in order to mark a challenge as solved.
|
||||
|
||||
:param team: The Team object from the database
|
||||
:param chal: The Challenge object from the database
|
||||
:param request: The request the user submitted
|
||||
:return:
|
||||
"""
|
||||
challenge = DynamicChallenge.query.filter_by(id=challenge.id).first()
|
||||
data = request.form or request.get_json()
|
||||
submission = data["submission"].strip()
|
||||
|
||||
solve = Solves(
|
||||
user_id=user.id,
|
||||
team_id=team.id if team else None,
|
||||
challenge_id=challenge.id,
|
||||
ip=get_ip(req=request),
|
||||
provided=submission,
|
||||
)
|
||||
db.session.add(solve)
|
||||
db.session.commit()
|
||||
@classmethod
|
||||
def solve(cls, user, team, challenge, request):
|
||||
super().solve(user, team, challenge, request)
|
||||
|
||||
DynamicValueChallenge.calculate_value(challenge)
|
||||
|
||||
@staticmethod
|
||||
def fail(user, team, challenge, request):
|
||||
"""
|
||||
This method is used to insert Fails into the database in order to mark an answer incorrect.
|
||||
|
||||
:param team: The Team object from the database
|
||||
:param challenge: The Challenge object from the database
|
||||
:param request: The request the user submitted
|
||||
:return:
|
||||
"""
|
||||
data = request.form or request.get_json()
|
||||
submission = data["submission"].strip()
|
||||
wrong = Fails(
|
||||
user_id=user.id,
|
||||
team_id=team.id if team else None,
|
||||
challenge_id=challenge.id,
|
||||
ip=get_ip(request),
|
||||
provided=submission,
|
||||
)
|
||||
db.session.add(wrong)
|
||||
db.session.commit()
|
||||
db.session.close()
|
||||
|
||||
|
||||
class DynamicChallenge(Challenges):
|
||||
__mapper_args__ = {"polymorphic_identity": "dynamic"}
|
||||
id = db.Column(
|
||||
db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
initial = db.Column(db.Integer, default=0)
|
||||
minimum = db.Column(db.Integer, default=0)
|
||||
decay = db.Column(db.Integer, default=0)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(DynamicChallenge, self).__init__(**kwargs)
|
||||
self.initial = kwargs["value"]
|
||||
|
||||
|
||||
def load(app):
|
||||
upgrade()
|
||||
|
||||
@@ -1,88 +1,43 @@
|
||||
<form method="POST" action="{{ script_root }}/admin/chal/new" enctype="multipart/form-data">
|
||||
<div class="form-group">
|
||||
<div class="alert alert-secondary" role="alert">
|
||||
Dynamic value challenges decrease in value as they receive solves. The more solves a dynamic challenge has,
|
||||
the
|
||||
lower its value is to everyone who has solved it.
|
||||
</div>
|
||||
</div>
|
||||
{% extends "admin/challenges/create.html" %}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name">Name<br>
|
||||
<small class="form-text text-muted">
|
||||
The name of your challenge
|
||||
</small>
|
||||
</label>
|
||||
<input type="text" class="form-control" name="name" placeholder="Enter challenge name">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="category">Category<br>
|
||||
<small class="form-text text-muted">
|
||||
The category of your challenge
|
||||
</small>
|
||||
</label>
|
||||
<input type="text" class="form-control" name="category" placeholder="Enter challenge category">
|
||||
</div>
|
||||
{% block header %}
|
||||
<div class="alert alert-secondary" role="alert">
|
||||
Dynamic value challenges decrease in value as they receive solves. The more solves a dynamic challenge has,
|
||||
the lower its value is to everyone who has solved it.
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
<ul class="nav nav-tabs" role="tablist" id="new-desc-edit">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" href="#new-desc-write" aria-controls="home" role="tab"
|
||||
data-toggle="tab">Write</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#new-desc-preview" aria-controls="home" role="tab" data-toggle="tab">Preview</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<div role="tabpanel" class="tab-pane active" id="new-desc-write">
|
||||
<div class="form-group">
|
||||
<label for="message-text" class="control-label">Message
|
||||
<small class="form-text text-muted">
|
||||
Use this to give a brief introduction to your challenge. The description supports HTML and
|
||||
Markdown.
|
||||
</small>
|
||||
</label>
|
||||
<textarea id="new-desc-editor" class="form-control" name="description" rows="10"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane content" id="new-desc-preview" style="height:234px; overflow-y: scroll;">
|
||||
</div>
|
||||
</div>
|
||||
{% block value %}
|
||||
<div class="form-group">
|
||||
<label for="value">Initial Value<br>
|
||||
<small class="form-text text-muted">
|
||||
This is how many points the challenge is worth initially.
|
||||
</small>
|
||||
</label>
|
||||
<input type="number" class="form-control" name="value" placeholder="Enter value" required>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="value">Initial Value<br>
|
||||
<small class="form-text text-muted">
|
||||
This is how many points the challenge is worth initially.
|
||||
</small>
|
||||
</label>
|
||||
<input type="number" class="form-control" name="value" placeholder="Enter value" required>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="value">Decay Limit<br>
|
||||
<small class="form-text text-muted">
|
||||
The amount of solves before the challenge reaches its minimum value
|
||||
</small>
|
||||
</label>
|
||||
<input type="number" class="form-control" name="decay" placeholder="Enter decay limit" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="value">Decay Limit<br>
|
||||
<small class="form-text text-muted">
|
||||
The amount of solves before the challenge reaches its minimum value
|
||||
</small>
|
||||
</label>
|
||||
<input type="number" class="form-control" name="decay" placeholder="Enter decay limit" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="value">Minimum Value<br>
|
||||
<small class="form-text text-muted">
|
||||
This is the lowest that the challenge can be worth
|
||||
</small>
|
||||
</label>
|
||||
<input type="number" class="form-control" name="minimum" placeholder="Enter minimum value" required>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="value">Minimum Value<br>
|
||||
<small class="form-text text-muted">
|
||||
This is the lowest that the challenge can be worth
|
||||
</small>
|
||||
</label>
|
||||
<input type="number" class="form-control" name="minimum" placeholder="Enter minimum value" required>
|
||||
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="state" value="hidden">
|
||||
<input type="hidden" value="dynamic" name="type" id="chaltype">
|
||||
|
||||
<div class="form-group">
|
||||
<button class="btn btn-primary float-right create-challenge-submit" type="submit">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
{% block type %}
|
||||
<input type="hidden" value="dynamic" name="type" id="chaltype">
|
||||
{% endblock %}
|
||||
@@ -1,29 +1,12 @@
|
||||
// Markdown Preview
|
||||
$('#desc-edit').on('shown.bs.tab', function (event) {
|
||||
if (event.target.hash == '#desc-preview'){
|
||||
var editor_value = $('#desc-editor').val();
|
||||
$(event.target.hash).html(
|
||||
window.challenge.render(editor_value)
|
||||
);
|
||||
}
|
||||
});
|
||||
$('#new-desc-edit').on('shown.bs.tab', function (event) {
|
||||
if (event.target.hash == '#new-desc-preview'){
|
||||
var editor_value = $('#new-desc-editor').val();
|
||||
$(event.target.hash).html(
|
||||
window.challenge.render(editor_value)
|
||||
);
|
||||
}
|
||||
});
|
||||
$("#solve-attempts-checkbox").change(function() {
|
||||
if(this.checked) {
|
||||
$('#solve-attempts-input').show();
|
||||
} else {
|
||||
$('#solve-attempts-input').hide();
|
||||
$('#max_attempts').val('');
|
||||
}
|
||||
});
|
||||
|
||||
$(document).ready(function(){
|
||||
$('[data-toggle="tooltip"]').tooltip();
|
||||
});
|
||||
CTFd.plugin.run((_CTFd) => {
|
||||
const $ = _CTFd.lib.$
|
||||
const md = _CTFd.lib.markdown()
|
||||
$('a[href="#new-desc-preview"]').on('shown.bs.tab', function (event) {
|
||||
if (event.target.hash == '#new-desc-preview') {
|
||||
var editor_value = $('#new-desc-editor').val();
|
||||
$(event.target.hash).html(
|
||||
md.render(editor_value)
|
||||
);
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
@@ -1,91 +1,39 @@
|
||||
<form method="POST">
|
||||
<div class="form-group">
|
||||
<label for="name">Name<br>
|
||||
<small class="form-text text-muted">
|
||||
The name of your challenge
|
||||
</small>
|
||||
</label>
|
||||
<input type="text" class="form-control chal-name" name="name" value="{{ challenge.name }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="category">Category<br>
|
||||
<small class="form-text text-muted">
|
||||
The category of your challenge
|
||||
</small>
|
||||
</label>
|
||||
<input type="text" class="form-control chal-category" name="category" value="{{ challenge.category }}">
|
||||
</div>
|
||||
{% extends "admin/challenges/update.html" %}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="message-text" class="control-label">Message<br>
|
||||
<small class="form-text text-muted">
|
||||
Use this to give a brief introduction to your challenge.
|
||||
</small>
|
||||
</label>
|
||||
<textarea id="desc-editor" class="form-control chal-desc-editor" name="description" rows="10">{{ challenge.description }}</textarea>
|
||||
</div>
|
||||
{% block value %}
|
||||
<div class="form-group">
|
||||
<label for="value">Current Value<br>
|
||||
<small class="form-text text-muted">
|
||||
This is how many points the challenge is worth right now.
|
||||
</small>
|
||||
</label>
|
||||
<input type="number" class="form-control chal-value" name="value" value="{{ challenge.value }}" disabled>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="value">Current Value<br>
|
||||
<small class="form-text text-muted">
|
||||
This is how many points the challenge is worth right now.
|
||||
</small>
|
||||
</label>
|
||||
<input type="number" class="form-control chal-value" name="value" value="{{ challenge.value }}" disabled>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="value">Initial Value<br>
|
||||
<small class="form-text text-muted">
|
||||
This is how many points the challenge was worth initially.
|
||||
</small>
|
||||
</label>
|
||||
<input type="number" class="form-control chal-initial" name="initial" value="{{ challenge.initial }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="value">Initial Value<br>
|
||||
<small class="form-text text-muted">
|
||||
This is how many points the challenge was worth initially.
|
||||
</small>
|
||||
</label>
|
||||
<input type="number" class="form-control chal-initial" name="initial" value="{{ challenge.initial }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="value">Decay Limit<br>
|
||||
<small class="form-text text-muted">
|
||||
The amount of solves before the challenge reaches its minimum value
|
||||
</small>
|
||||
</label>
|
||||
<input type="number" class="form-control chal-decay" name="decay" value="{{ challenge.decay }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="value">Decay Limit<br>
|
||||
<small class="form-text text-muted">
|
||||
The amount of solves before the challenge reaches its minimum value
|
||||
</small>
|
||||
</label>
|
||||
<input type="number" class="form-control chal-decay" name="decay" value="{{ challenge.decay }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="value">Minimum Value<br>
|
||||
<small class="form-text text-muted">
|
||||
This is the lowest that the challenge can be worth
|
||||
</small>
|
||||
</label>
|
||||
<input type="number" class="form-control chal-minimum" name="minimum" value="{{ challenge.minimum }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
Max Attempts<br>
|
||||
<small class="form-text text-muted">Maximum amount of attempts users receive. Leave at 0 for unlimited.</small>
|
||||
</label>
|
||||
|
||||
<input type="number" class="form-control chal-attempts" name="max_attempts"
|
||||
value="{{ challenge.max_attempts }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
State<br>
|
||||
<small class="form-text text-muted">Changes the state of the challenge (e.g. visible, hidden)</small>
|
||||
</label>
|
||||
|
||||
<select class="form-control custom-select" name="state">
|
||||
<option value="visible" {% if challenge.state == "visible" %}selected{% endif %}>Visible</option>
|
||||
<option value="hidden" {% if challenge.state == "hidden" %}selected{% endif %}>Hidden</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button class="btn btn-success btn-outlined float-right" type="submit">
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="form-group">
|
||||
<label for="value">Minimum Value<br>
|
||||
<small class="form-text text-muted">
|
||||
This is the lowest that the challenge can be worth
|
||||
</small>
|
||||
</label>
|
||||
<input type="number" class="form-control chal-minimum" name="minimum" value="{{ challenge.minimum }}" required>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,118 +1,16 @@
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-body">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
<ul class="nav nav-tabs">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" href="#challenge">Challenge</a>
|
||||
</li>
|
||||
{% if solves == None %}
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link challenge-solves" href="#solves">
|
||||
{{ solves }} {% if solves > 1 %}Solves{% else %}Solves{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<div role="tabpanel">
|
||||
<div class="tab-content">
|
||||
<div role="tabpanel" class="tab-pane fade show active" id="challenge">
|
||||
<h2 class='challenge-name text-center pt-3'>{{ name }}</h2>
|
||||
<h3 class="challenge-value text-center">{{ value }}</h3>
|
||||
<div class="challenge-tags text-center">
|
||||
{% for tag in tags %}
|
||||
<span class='badge badge-info challenge-tag'>{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<span class="challenge-desc">{{ description | safe }}</span>
|
||||
<div class="challenge-hints hint-row row">
|
||||
{% for hint in hints %}
|
||||
<div class='col-md-12 hint-button-wrapper text-center mb-3'>
|
||||
<a class="btn btn-info btn-hint btn-block load-hint" href="javascript:;" data-hint-id="{{ hint.id }}">
|
||||
{% if hint.hint %}
|
||||
<small>
|
||||
View Hint
|
||||
</small>
|
||||
{% else %}
|
||||
{% if hint.cost %}
|
||||
<small>
|
||||
Unlock Hint for {{ hint.cost }} points
|
||||
</small>
|
||||
{% else %}
|
||||
<small>
|
||||
View Hint
|
||||
</small>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="row challenge-files text-center pb-3">
|
||||
{% for file in files %}
|
||||
<div class='col-md-4 col-sm-4 col-xs-12 file-button-wrapper d-block'>
|
||||
<a class='btn btn-info btn-file mb-1 d-inline-block px-2 w-100 text-truncate'
|
||||
href='{{ file }}'>
|
||||
<i class="fas fa-download"></i>
|
||||
<small>
|
||||
{% set segments = file.split('/') %}
|
||||
{% set file = segments | last %}
|
||||
{% set token = file.split('?') | last %}
|
||||
{% if token %}
|
||||
{{ file | replace("?" + token, "") }}
|
||||
{% else %}
|
||||
{{ file }}
|
||||
{% endif %}
|
||||
</small>
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% extends "challenge.html" %}
|
||||
|
||||
<div class="row submit-row">
|
||||
<div class="col-md-9 form-group">
|
||||
<input class="form-control" type="text" name="answer" id="submission-input"
|
||||
placeholder="Flag"/>
|
||||
<input id="challenge-id" type="hidden" value="{{ id }}">
|
||||
</div>
|
||||
<div class="col-md-3 form-group key-submit">
|
||||
<button type="submit" id="submit-key" tabindex="0"
|
||||
class="btn btn-md btn-outline-secondary float-right">Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row notification-row">
|
||||
<div class="col-md-12">
|
||||
<div id="result-notification" class="alert alert-dismissable text-center w-100"
|
||||
role="alert" style="display: none;">
|
||||
<strong id="result-message"></strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane fade" id="solves">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<table class="table table-striped text-center">
|
||||
<thead>
|
||||
<tr>
|
||||
<td><b>Name</b>
|
||||
</td>
|
||||
<td><b>Date</b>
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="challenge-solves-names">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% block description %}
|
||||
{{ challenge.html }}
|
||||
{% endblock %}
|
||||
|
||||
{% block input %}
|
||||
<input id="challenge-id" class="challenge-id" type="hidden" value="{{ challenge.id }}">
|
||||
<input id="challenge-input" class="challenge-input" type="text" name="answer" placeholder="Flag"/>
|
||||
{% endblock %}
|
||||
|
||||
{% block submit %}
|
||||
<button id="challenge-submit" class="challenge-submit" type="submit">
|
||||
Submit
|
||||
</button>
|
||||
{% endblock %}
|
||||
@@ -3,6 +3,14 @@ import re
|
||||
from CTFd.plugins import register_plugin_assets_directory
|
||||
|
||||
|
||||
class FlagException(Exception):
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
|
||||
def __str__(self):
|
||||
return self.message
|
||||
|
||||
|
||||
class BaseFlag(object):
|
||||
name = None
|
||||
templates = {}
|
||||
@@ -55,8 +63,8 @@ class CTFdRegexFlag(BaseFlag):
|
||||
else:
|
||||
res = re.match(saved, provided)
|
||||
# TODO: this needs plugin improvements. See #1425.
|
||||
except re.error:
|
||||
return False
|
||||
except re.error as e:
|
||||
raise FlagException("Regex parse error occured") from e
|
||||
|
||||
return res and res.group() == provided
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from flask import Blueprint, render_template
|
||||
from CTFd.cache import cache, make_cache_key
|
||||
from CTFd.utils import config
|
||||
from CTFd.utils.decorators.visibility import check_score_visibility
|
||||
from CTFd.utils.helpers import get_infos
|
||||
from CTFd.utils.scores import get_standings
|
||||
|
||||
scoreboard = Blueprint("scoreboard", __name__)
|
||||
@@ -12,9 +13,10 @@ scoreboard = Blueprint("scoreboard", __name__)
|
||||
@check_score_visibility
|
||||
@cache.cached(timeout=60, key_prefix=make_cache_key)
|
||||
def listing():
|
||||
infos = get_infos()
|
||||
|
||||
if config.is_scoreboard_frozen():
|
||||
infos.append("Scoreboard has been frozen")
|
||||
|
||||
standings = get_standings()
|
||||
return render_template(
|
||||
"scoreboard.html",
|
||||
standings=standings,
|
||||
score_frozen=config.is_scoreboard_frozen(),
|
||||
)
|
||||
return render_template("scoreboard.html", standings=standings, infos=infos)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from flask import Blueprint, redirect, render_template, request, url_for
|
||||
|
||||
from CTFd.cache import clear_user_session, clear_team_session
|
||||
from CTFd.cache import clear_team_session, clear_user_session
|
||||
from CTFd.models import Teams, db
|
||||
from CTFd.utils import config, get_config
|
||||
from CTFd.utils.crypto import verify_password
|
||||
@@ -20,25 +20,34 @@ teams = Blueprint("teams", __name__)
|
||||
@check_account_visibility
|
||||
@require_team_mode
|
||||
def listing():
|
||||
page = abs(request.args.get("page", 1, type=int))
|
||||
results_per_page = 50
|
||||
page_start = results_per_page * (page - 1)
|
||||
page_end = results_per_page * (page - 1) + results_per_page
|
||||
q = request.args.get("q")
|
||||
field = request.args.get("field", "name")
|
||||
filters = []
|
||||
|
||||
if field not in ("name", "affiliation", "website"):
|
||||
field = "name"
|
||||
|
||||
if q:
|
||||
filters.append(getattr(Teams, field).like("%{}%".format(q)))
|
||||
|
||||
# TODO: Should teams confirm emails?
|
||||
# if get_config('verify_emails'):
|
||||
# count = Teams.query.filter_by(verified=True, banned=False).count()
|
||||
# teams = Teams.query.filter_by(verified=True, banned=False).slice(page_start, page_end).all()
|
||||
# else:
|
||||
count = Teams.query.filter_by(hidden=False, banned=False).count()
|
||||
teams = (
|
||||
Teams.query.filter_by(hidden=False, banned=False)
|
||||
.slice(page_start, page_end)
|
||||
.all()
|
||||
.filter(*filters)
|
||||
.order_by(Teams.id.asc())
|
||||
.paginate(per_page=50)
|
||||
)
|
||||
|
||||
pages = int(count / results_per_page) + (count % results_per_page > 0)
|
||||
return render_template("teams/teams.html", teams=teams, pages=pages, curr_page=page)
|
||||
args = dict(request.args)
|
||||
args.pop("page", 1)
|
||||
|
||||
return render_template(
|
||||
"teams/teams.html",
|
||||
teams=teams,
|
||||
prev_page=url_for(request.endpoint, page=teams.prev_num, **args),
|
||||
next_page=url_for(request.endpoint, page=teams.next_num, **args),
|
||||
q=q,
|
||||
field=field,
|
||||
)
|
||||
|
||||
|
||||
@teams.route("/teams/join", methods=["GET", "POST"])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@import "includes/sticky-footer.css";
|
||||
|
||||
#score-graph {
|
||||
height: 450px;
|
||||
min-height: 400px;
|
||||
display: block;
|
||||
clear: both;
|
||||
}
|
||||
@@ -12,17 +12,22 @@
|
||||
}
|
||||
|
||||
#keys-pie-graph {
|
||||
height: 400px;
|
||||
min-height: 400px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#categories-pie-graph {
|
||||
height: 400px;
|
||||
min-height: 400px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#solve-percentages-graph {
|
||||
height: 400px;
|
||||
min-height: 400px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#score-distribution-graph {
|
||||
min-height: 400px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,6 @@
|
||||
#challenge-window .form-control:focus {
|
||||
background-color: transparent;
|
||||
border-color: #a3d39c;
|
||||
box-shadow: 0 0 0 0.2rem #a3d39c;
|
||||
box-shadow: 0 0 0 0.1rem #a3d39c;
|
||||
transition: background-color 0.3s, border-color 0.3s;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
@import "~codemirror/lib/codemirror.css";
|
||||
.CodeMirror {
|
||||
@import "includes/easymde.scss";
|
||||
.CodeMirror.cm-s-default {
|
||||
font-size: 12px;
|
||||
border: 1px solid lightgray;
|
||||
}
|
||||
|
||||
382
CTFd/themes/admin/assets/css/includes/easymde.scss
Normal file
382
CTFd/themes/admin/assets/css/includes/easymde.scss
Normal file
@@ -0,0 +1,382 @@
|
||||
.CodeMirror.cm-s-easymde {
|
||||
box-sizing: border-box;
|
||||
height: auto;
|
||||
border: 1px solid lightgray;
|
||||
padding: 10px;
|
||||
font: inherit;
|
||||
z-index: 0;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.CodeMirror-scroll {
|
||||
overflow-y: hidden;
|
||||
overflow-x: auto;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
position: relative;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
-o-user-select: none;
|
||||
user-select: none;
|
||||
padding: 0 10px;
|
||||
border-top: 1px solid #bbb;
|
||||
border-left: 1px solid #bbb;
|
||||
border-right: 1px solid #bbb;
|
||||
}
|
||||
|
||||
.editor-toolbar:after,
|
||||
.editor-toolbar:before {
|
||||
display: block;
|
||||
content: " ";
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.editor-toolbar:before {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.editor-toolbar:after {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.editor-toolbar.fullscreen {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
border: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 1;
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
.editor-toolbar.fullscreen::before {
|
||||
width: 20px;
|
||||
height: 50px;
|
||||
background: -moz-linear-gradient(
|
||||
left,
|
||||
rgba(255, 255, 255, 1) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
background: -webkit-gradient(
|
||||
linear,
|
||||
left top,
|
||||
right top,
|
||||
color-stop(0%, rgba(255, 255, 255, 1)),
|
||||
color-stop(100%, rgba(255, 255, 255, 0))
|
||||
);
|
||||
background: -webkit-linear-gradient(
|
||||
left,
|
||||
rgba(255, 255, 255, 1) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
background: -o-linear-gradient(
|
||||
left,
|
||||
rgba(255, 255, 255, 1) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
background: -ms-linear-gradient(
|
||||
left,
|
||||
rgba(255, 255, 255, 1) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgba(255, 255, 255, 1) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.editor-toolbar.fullscreen::after {
|
||||
width: 20px;
|
||||
height: 50px;
|
||||
background: -moz-linear-gradient(
|
||||
left,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 1) 100%
|
||||
);
|
||||
background: -webkit-gradient(
|
||||
linear,
|
||||
left top,
|
||||
right top,
|
||||
color-stop(0%, rgba(255, 255, 255, 0)),
|
||||
color-stop(100%, rgba(255, 255, 255, 1))
|
||||
);
|
||||
background: -webkit-linear-gradient(
|
||||
left,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 1) 100%
|
||||
);
|
||||
background: -o-linear-gradient(
|
||||
left,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 1) 100%
|
||||
);
|
||||
background: -ms-linear-gradient(
|
||||
left,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 1) 100%
|
||||
);
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 1) 100%
|
||||
);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.editor-toolbar button,
|
||||
.editor-toolbar .easymde-dropdown {
|
||||
background: transparent;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
text-decoration: none !important;
|
||||
height: 30px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editor-toolbar button {
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.editor-toolbar button.active,
|
||||
.editor-toolbar button:hover {
|
||||
background: #fcfcfc;
|
||||
border-color: #95a5a6;
|
||||
}
|
||||
|
||||
.editor-toolbar i.separator {
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
border-left: 1px solid #d9d9d9;
|
||||
border-right: 1px solid #fff;
|
||||
color: transparent;
|
||||
text-indent: -10px;
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
.editor-toolbar button:after {
|
||||
font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
|
||||
font-size: 65%;
|
||||
vertical-align: text-bottom;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
.editor-toolbar button.heading-1:after {
|
||||
content: "1";
|
||||
}
|
||||
|
||||
.editor-toolbar button.heading-2:after {
|
||||
content: "2";
|
||||
}
|
||||
|
||||
.editor-toolbar button.heading-3:after {
|
||||
content: "3";
|
||||
}
|
||||
|
||||
.editor-toolbar button.heading-bigger:after {
|
||||
content: "▲";
|
||||
}
|
||||
|
||||
.editor-toolbar button.heading-smaller:after {
|
||||
content: "▼";
|
||||
}
|
||||
|
||||
.editor-toolbar.disabled-for-preview button:not(.no-disable) {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 700px) {
|
||||
.editor-toolbar i.no-mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-statusbar {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
color: #959694;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.editor-statusbar span {
|
||||
display: inline-block;
|
||||
min-width: 4em;
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
.editor-statusbar .lines:before {
|
||||
content: "lines: ";
|
||||
}
|
||||
|
||||
.editor-statusbar .words:before {
|
||||
content: "words: ";
|
||||
}
|
||||
|
||||
.editor-statusbar .characters:before {
|
||||
content: "characters: ";
|
||||
}
|
||||
|
||||
.editor-preview-full {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 7;
|
||||
overflow: auto;
|
||||
display: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.editor-preview-side {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
width: 50%;
|
||||
top: 50px;
|
||||
right: 0;
|
||||
z-index: 9;
|
||||
overflow: auto;
|
||||
display: none;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ddd;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.editor-preview-active-side {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.editor-preview-active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.editor-preview {
|
||||
padding: 10px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.editor-preview > p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.editor-preview pre {
|
||||
background: #eee;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.editor-preview table td,
|
||||
.editor-preview table th {
|
||||
border: 1px solid #ddd;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.cm-s-easymde .cm-tag {
|
||||
color: #63a35c;
|
||||
}
|
||||
|
||||
.cm-s-easymde .cm-attribute {
|
||||
color: #795da3;
|
||||
}
|
||||
|
||||
.cm-s-easymde .cm-string {
|
||||
color: #183691;
|
||||
}
|
||||
|
||||
.cm-s-easymde .cm-header-1 {
|
||||
font-size: 200%;
|
||||
line-height: 200%;
|
||||
}
|
||||
|
||||
.cm-s-easymde .cm-header-2 {
|
||||
font-size: 160%;
|
||||
line-height: 160%;
|
||||
}
|
||||
|
||||
.cm-s-easymde .cm-header-3 {
|
||||
font-size: 125%;
|
||||
line-height: 125%;
|
||||
}
|
||||
|
||||
.cm-s-easymde .cm-header-4 {
|
||||
font-size: 110%;
|
||||
line-height: 110%;
|
||||
}
|
||||
|
||||
.cm-s-easymde .cm-comment {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.cm-s-easymde .cm-link {
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.cm-s-easymde .cm-url {
|
||||
color: #aab2b3;
|
||||
}
|
||||
|
||||
.cm-s-easymde .cm-quote {
|
||||
color: #7f8c8d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.editor-toolbar .easymde-dropdown {
|
||||
position: relative;
|
||||
background: linear-gradient(
|
||||
to bottom right,
|
||||
#fff 0%,
|
||||
#fff 84%,
|
||||
#333 50%,
|
||||
#333 100%
|
||||
);
|
||||
border-radius: 0;
|
||||
border: 1px solid #fff;
|
||||
}
|
||||
|
||||
.editor-toolbar .easymde-dropdown:hover {
|
||||
background: linear-gradient(
|
||||
to bottom right,
|
||||
#fff 0%,
|
||||
#fff 84%,
|
||||
#333 50%,
|
||||
#333 100%
|
||||
);
|
||||
}
|
||||
|
||||
.easymde-dropdown-content {
|
||||
display: none;
|
||||
position: absolute;
|
||||
background-color: #f9f9f9;
|
||||
box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.2);
|
||||
padding: 8px;
|
||||
z-index: 2;
|
||||
top: 30px;
|
||||
}
|
||||
|
||||
.easymde-dropdown:active .easymde-dropdown-content,
|
||||
.easymde-dropdown:focus .easymde-dropdown-content {
|
||||
display: block;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import $ from "jquery";
|
||||
import { ezToast } from "core/ezq";
|
||||
import { ezToast, ezQuery } from "core/ezq";
|
||||
import { htmlEntities } from "core/utils";
|
||||
import CTFd from "core/CTFd";
|
||||
import nunjucks from "nunjucks";
|
||||
|
||||
@@ -90,105 +91,85 @@ function renderSubmissionResponse(response, cb) {
|
||||
}
|
||||
|
||||
$(() => {
|
||||
$(".preview-challenge").click(function(event) {
|
||||
$(".preview-challenge").click(function(_event) {
|
||||
window.challenge = new Object();
|
||||
$.get(CTFd.config.urlRoot + "/api/v1/challenges/" + CHALLENGE_ID, function(
|
||||
response
|
||||
) {
|
||||
const challenge_data = response.data;
|
||||
challenge_data["solves"] = null;
|
||||
$.get(
|
||||
CTFd.config.urlRoot + "/api/v1/challenges/" + window.CHALLENGE_ID,
|
||||
function(response) {
|
||||
const challenge_data = response.data;
|
||||
challenge_data["solves"] = null;
|
||||
|
||||
$.getScript(
|
||||
CTFd.config.urlRoot + challenge_data.type_data.scripts.view,
|
||||
function() {
|
||||
$.get(
|
||||
CTFd.config.urlRoot + challenge_data.type_data.templates.view,
|
||||
function(template_data) {
|
||||
$("#challenge-window").empty();
|
||||
const template = nunjucks.compile(template_data);
|
||||
window.challenge.data = challenge_data;
|
||||
window.challenge.preRender();
|
||||
$.getScript(
|
||||
CTFd.config.urlRoot + challenge_data.type_data.scripts.view,
|
||||
function() {
|
||||
$.get(
|
||||
CTFd.config.urlRoot + challenge_data.type_data.templates.view,
|
||||
function(template_data) {
|
||||
$("#challenge-window").empty();
|
||||
const template = nunjucks.compile(template_data);
|
||||
window.challenge.data = challenge_data;
|
||||
window.challenge.preRender();
|
||||
|
||||
challenge_data["description"] = window.challenge.render(
|
||||
challenge_data["description"]
|
||||
);
|
||||
challenge_data["script_root"] = CTFd.config.urlRoot;
|
||||
challenge_data["description"] = window.challenge.render(
|
||||
challenge_data["description"]
|
||||
);
|
||||
challenge_data["script_root"] = CTFd.config.urlRoot;
|
||||
|
||||
$("#challenge-window").append(template.render(challenge_data));
|
||||
$("#challenge-window").append(template.render(challenge_data));
|
||||
|
||||
$(".challenge-solves").click(function(event) {
|
||||
getsolves($("#challenge-id").val());
|
||||
});
|
||||
$(".nav-tabs a").click(function(event) {
|
||||
event.preventDefault();
|
||||
$(this).tab("show");
|
||||
});
|
||||
$(".nav-tabs a").click(function(event) {
|
||||
event.preventDefault();
|
||||
$(this).tab("show");
|
||||
});
|
||||
|
||||
// Handle modal toggling
|
||||
$("#challenge-window").on("hide.bs.modal", function(event) {
|
||||
$("#submission-input").removeClass("wrong");
|
||||
$("#submission-input").removeClass("correct");
|
||||
$("#incorrect-key").slideUp();
|
||||
$("#correct-key").slideUp();
|
||||
$("#already-solved").slideUp();
|
||||
$("#too-fast").slideUp();
|
||||
});
|
||||
// Handle modal toggling
|
||||
$("#challenge-window").on("hide.bs.modal", function(_event) {
|
||||
$("#submission-input").removeClass("wrong");
|
||||
$("#submission-input").removeClass("correct");
|
||||
$("#incorrect-key").slideUp();
|
||||
$("#correct-key").slideUp();
|
||||
$("#already-solved").slideUp();
|
||||
$("#too-fast").slideUp();
|
||||
});
|
||||
|
||||
$("#submit-key").click(function(event) {
|
||||
event.preventDefault();
|
||||
$("#submit-key").addClass("disabled-button");
|
||||
$("#submit-key").prop("disabled", true);
|
||||
window.challenge.submit(function(data) {
|
||||
renderSubmissionResponse(data);
|
||||
}, true);
|
||||
// Preview passed as true
|
||||
});
|
||||
$("#submit-key").click(function(event) {
|
||||
event.preventDefault();
|
||||
$("#submit-key").addClass("disabled-button");
|
||||
$("#submit-key").prop("disabled", true);
|
||||
window.challenge.submit(function(data) {
|
||||
renderSubmissionResponse(data);
|
||||
}, true);
|
||||
// Preview passed as true
|
||||
});
|
||||
|
||||
$("#submission-input").keyup(function(event) {
|
||||
if (event.keyCode == 13) {
|
||||
$("#submit-key").click();
|
||||
}
|
||||
});
|
||||
|
||||
$(".input-field").bind({
|
||||
focus: function() {
|
||||
$(this)
|
||||
.parent()
|
||||
.addClass("input--filled");
|
||||
$label = $(this).siblings(".input-label");
|
||||
},
|
||||
blur: function() {
|
||||
if ($(this).val() === "") {
|
||||
$(this)
|
||||
.parent()
|
||||
.removeClass("input--filled");
|
||||
$label = $(this).siblings(".input-label");
|
||||
$label.removeClass("input--hide");
|
||||
$("#submission-input").keyup(function(event) {
|
||||
if (event.keyCode == 13) {
|
||||
$("#submit-key").click();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
window.challenge.postRender();
|
||||
window.location.replace(
|
||||
window.location.href.split("#")[0] + "#preview"
|
||||
);
|
||||
window.challenge.postRender();
|
||||
window.location.replace(
|
||||
window.location.href.split("#")[0] + "#preview"
|
||||
);
|
||||
|
||||
$("#challenge-window").modal();
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
$("#challenge-window").modal();
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$(".delete-challenge").click(function(event) {
|
||||
$(".delete-challenge").click(function(_event) {
|
||||
ezQuery({
|
||||
title: "Delete Challenge",
|
||||
body: "Are you sure you want to delete {0}".format(
|
||||
"<strong>" + htmlentities(CHALLENGE_NAME) + "</strong>"
|
||||
"<strong>" + htmlEntities(window.CHALLENGE_NAME) + "</strong>"
|
||||
),
|
||||
success: function() {
|
||||
CTFd.fetch("/api/v1/challenges/" + CHALLENGE_ID, {
|
||||
CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID, {
|
||||
method: "DELETE"
|
||||
}).then(function(response) {
|
||||
if (response.success) {
|
||||
@@ -203,7 +184,7 @@ $(() => {
|
||||
event.preventDefault();
|
||||
const params = $(event.target).serializeJSON(true);
|
||||
|
||||
CTFd.fetch("/api/v1/challenges/" + CHALLENGE_ID, {
|
||||
CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID, {
|
||||
method: "PATCH",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
|
||||
@@ -7,17 +7,17 @@ export function addFile(event) {
|
||||
event.preventDefault();
|
||||
let form = event.target;
|
||||
let data = {
|
||||
challenge: CHALLENGE_ID,
|
||||
challenge: window.CHALLENGE_ID,
|
||||
type: "challenge"
|
||||
};
|
||||
helpers.files.upload(form, data, function(response) {
|
||||
helpers.files.upload(form, data, function(_response) {
|
||||
setTimeout(function() {
|
||||
window.location.reload();
|
||||
}, 700);
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteFile(event) {
|
||||
export function deleteFile(_event) {
|
||||
const file_id = $(this).attr("file-id");
|
||||
const row = $(this)
|
||||
.parent()
|
||||
|
||||
@@ -29,7 +29,7 @@ export function deleteFlag(event) {
|
||||
});
|
||||
}
|
||||
|
||||
export function addFlagModal(event) {
|
||||
export function addFlagModal(_event) {
|
||||
$.get(CTFd.config.urlRoot + "/api/v1/flags/types", function(response) {
|
||||
const data = response.data;
|
||||
const flag_type_select = $("#flags-create-select");
|
||||
@@ -52,7 +52,7 @@ export function addFlagModal(event) {
|
||||
$("#flag-edit-modal form").submit(function(event) {
|
||||
event.preventDefault();
|
||||
const params = $(this).serializeJSON(true);
|
||||
params["challenge"] = CHALLENGE_ID;
|
||||
params["challenge"] = window.CHALLENGE_ID;
|
||||
CTFd.fetch("/api/v1/flags", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
@@ -65,7 +65,7 @@ export function addFlagModal(event) {
|
||||
.then(function(response) {
|
||||
return response.json();
|
||||
})
|
||||
.then(function(response) {
|
||||
.then(function(_response) {
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,50 +1,18 @@
|
||||
import $ from "jquery";
|
||||
import CTFd from "core/CTFd";
|
||||
import { ezQuery, ezAlert } from "core/ezq";
|
||||
|
||||
function hint(id) {
|
||||
return CTFd.fetch("/api/v1/hints/" + id + "?preview=true", {
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadhint(hintid) {
|
||||
const md = CTFd.lib.markdown();
|
||||
|
||||
hint(hintid).then(function(response) {
|
||||
if (response.data.content) {
|
||||
ezAlert({
|
||||
title: "Hint",
|
||||
body: md.render(response.data.content),
|
||||
button: "Got it!"
|
||||
});
|
||||
} else {
|
||||
ezAlert({
|
||||
title: "Error",
|
||||
body: "Error loading hint!",
|
||||
button: "OK"
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
import { ezQuery } from "core/ezq";
|
||||
|
||||
export function showHintModal(event) {
|
||||
event.preventDefault();
|
||||
$("#hint-edit-modal form")
|
||||
.find("input, textarea")
|
||||
.val("");
|
||||
.val("")
|
||||
// Trigger a change on the textarea to get codemirror to clone changes in
|
||||
.trigger("change");
|
||||
|
||||
// Markdown Preview
|
||||
$("#new-hint-edit").on("shown.bs.tab", function(event) {
|
||||
if (event.target.hash == "#hint-preview") {
|
||||
const renderer = CTFd.lib.markdown();
|
||||
const editor_value = $("#hint-write textarea").val();
|
||||
$(event.target.hash).html(renderer.render(editor_value));
|
||||
$("#hint-edit-form textarea").each(function(i, e) {
|
||||
if (e.hasOwnProperty("codemirror")) {
|
||||
e.codemirror.refresh();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -68,21 +36,33 @@ export function showEditHintModal(event) {
|
||||
})
|
||||
.then(function(response) {
|
||||
if (response.success) {
|
||||
$("#hint-edit-form input[name=content],textarea[name=content]").val(
|
||||
response.data.content
|
||||
);
|
||||
$("#hint-edit-form input[name=content],textarea[name=content]")
|
||||
.val(response.data.content)
|
||||
// Trigger a change on the textarea to get codemirror to clone changes in
|
||||
.trigger("change");
|
||||
|
||||
$("#hint-edit-modal")
|
||||
.on("shown.bs.modal", function() {
|
||||
$("#hint-edit-form textarea").each(function(i, e) {
|
||||
if (e.hasOwnProperty("codemirror")) {
|
||||
e.codemirror.refresh();
|
||||
}
|
||||
});
|
||||
})
|
||||
.on("hide.bs.modal", function() {
|
||||
$("#hint-edit-form textarea").each(function(i, e) {
|
||||
$(e)
|
||||
.val("")
|
||||
.trigger("change");
|
||||
if (e.hasOwnProperty("codemirror")) {
|
||||
e.codemirror.refresh();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$("#hint-edit-form input[name=cost]").val(response.data.cost);
|
||||
$("#hint-edit-form input[name=id]").val(response.data.id);
|
||||
|
||||
// Markdown Preview
|
||||
$("#new-hint-edit").on("shown.bs.tab", function(event) {
|
||||
if (event.target.hash == "#hint-preview") {
|
||||
const renderer = CTFd.lib.markdown();
|
||||
const editor_value = $("#hint-write textarea").val();
|
||||
$(event.target.hash).html(renderer.render(editor_value));
|
||||
}
|
||||
});
|
||||
|
||||
$("#hint-edit-modal").modal();
|
||||
}
|
||||
});
|
||||
@@ -116,7 +96,7 @@ export function deleteHint(event) {
|
||||
export function editHint(event) {
|
||||
event.preventDefault();
|
||||
const params = $(this).serializeJSON(true);
|
||||
params["challenge"] = CHALLENGE_ID;
|
||||
params["challenge"] = window.CHALLENGE_ID;
|
||||
|
||||
let method = "POST";
|
||||
let url = "/api/v1/hints";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import CTFd from "core/CTFd";
|
||||
import nunjucks from "nunjucks";
|
||||
import $ from "jquery";
|
||||
|
||||
window.challenge = new Object();
|
||||
@@ -63,7 +64,7 @@ $.get(CTFd.config.urlRoot + "/api/v1/challenges/types", function(response) {
|
||||
}
|
||||
});
|
||||
|
||||
function createChallenge(event) {
|
||||
function createChallenge(_event) {
|
||||
const challenge = $(this)
|
||||
.find("option:selected")
|
||||
.data("meta");
|
||||
|
||||
@@ -10,15 +10,15 @@ export function addRequirement(event) {
|
||||
return;
|
||||
}
|
||||
|
||||
CHALLENGE_REQUIREMENTS.prerequisites.push(
|
||||
window.CHALLENGE_REQUIREMENTS.prerequisites.push(
|
||||
parseInt(requirements["prerequisite"])
|
||||
);
|
||||
|
||||
const params = {
|
||||
requirements: CHALLENGE_REQUIREMENTS
|
||||
requirements: window.CHALLENGE_REQUIREMENTS
|
||||
};
|
||||
|
||||
CTFd.fetch("/api/v1/challenges/" + CHALLENGE_ID, {
|
||||
CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID, {
|
||||
method: "PATCH",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
@@ -38,18 +38,18 @@ export function addRequirement(event) {
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteRequirement(event) {
|
||||
export function deleteRequirement(_event) {
|
||||
const challenge_id = $(this).attr("challenge-id");
|
||||
const row = $(this)
|
||||
.parent()
|
||||
.parent();
|
||||
|
||||
CHALLENGE_REQUIREMENTS.prerequisites.pop(challenge_id);
|
||||
window.CHALLENGE_REQUIREMENTS.prerequisites.pop(challenge_id);
|
||||
|
||||
const params = {
|
||||
requirements: CHALLENGE_REQUIREMENTS
|
||||
requirements: window.CHALLENGE_REQUIREMENTS
|
||||
};
|
||||
CTFd.fetch("/api/v1/challenges/" + CHALLENGE_ID, {
|
||||
CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID, {
|
||||
method: "PATCH",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import $ from "jquery";
|
||||
import CTFd from "core/CTFd";
|
||||
|
||||
export function deleteTag(event) {
|
||||
export function deleteTag(_event) {
|
||||
const $elem = $(this);
|
||||
const tag_id = $elem.attr("tag-id");
|
||||
|
||||
@@ -22,7 +22,7 @@ export function addTag(event) {
|
||||
const tag = $elem.val();
|
||||
const params = {
|
||||
value: tag,
|
||||
challenge: CHALLENGE_ID
|
||||
challenge: window.CHALLENGE_ID
|
||||
};
|
||||
|
||||
CTFd.api.post_tag_list({}, params).then(response => {
|
||||
|
||||
332
CTFd/themes/admin/assets/js/components/files/MediaLibrary.vue
Normal file
332
CTFd/themes/admin/assets/js/components/files/MediaLibrary.vue
Normal file
@@ -0,0 +1,332 @@
|
||||
<template>
|
||||
<div id="media-modal" class="modal fade" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h3 class="text-center">Media Library</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="close"
|
||||
data-dismiss="modal"
|
||||
aria-label="Close"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-header">
|
||||
<div class="container">
|
||||
<div class="row mh-100">
|
||||
<div class="col-md-6" id="media-library-list">
|
||||
<div
|
||||
class="media-item-wrapper"
|
||||
v-for="file in files"
|
||||
:key="file.id"
|
||||
>
|
||||
<a
|
||||
href="javascript:void(0)"
|
||||
@click="
|
||||
selectFile(file);
|
||||
return false;
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-bind:class="getIconClass(file.location)"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<small class="media-item-title">{{
|
||||
file.location.split("/").pop()
|
||||
}}</small>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6" id="media-library-details">
|
||||
<h4 class="text-center">Media Details</h4>
|
||||
<div id="media-item">
|
||||
<div class="text-center" id="media-icon">
|
||||
<div v-if="this.selectedFile">
|
||||
<div
|
||||
v-if="
|
||||
getIconClass(this.selectedFile.location) ===
|
||||
'far fa-file-image'
|
||||
"
|
||||
>
|
||||
<img
|
||||
v-bind:src="buildSelectedFileUrl()"
|
||||
style="max-width: 100%; max-height: 100%; object-fit: contain;"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<i
|
||||
v-bind:class="
|
||||
`${getIconClass(
|
||||
this.selectedFile.location
|
||||
)} fa-4x`
|
||||
"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<div
|
||||
class="text-center"
|
||||
id="media-filename"
|
||||
v-if="this.selectedFile"
|
||||
>
|
||||
<a v-bind:href="buildSelectedFileUrl()" target="_blank">
|
||||
{{ this.selectedFile.location.split("/").pop() }}
|
||||
</a>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<div class="form-group">
|
||||
<div v-if="this.selectedFile">
|
||||
Link:
|
||||
<input
|
||||
class="form-control"
|
||||
type="text"
|
||||
id="media-link"
|
||||
v-bind:value="buildSelectedFileUrl()"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
Link:
|
||||
<input
|
||||
class="form-control"
|
||||
type="text"
|
||||
id="media-link"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group text-center">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<button
|
||||
@click="insertSelectedFile"
|
||||
class="btn btn-success w-100"
|
||||
id="media-insert"
|
||||
data-toggle="tooltip"
|
||||
data-placement="top"
|
||||
title="Insert link into editor"
|
||||
>
|
||||
Insert
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button
|
||||
@click="downloadSelectedFile"
|
||||
class="btn btn-primary w-100"
|
||||
id="media-download"
|
||||
data-toggle="tooltip"
|
||||
data-placement="top"
|
||||
title="Download file"
|
||||
>
|
||||
<i class="fas fa-download"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button
|
||||
@click="deleteSelectedFile"
|
||||
class="btn btn-danger w-100"
|
||||
id="media-delete"
|
||||
data-toggle="tooltip"
|
||||
data-placement="top"
|
||||
title="Delete file"
|
||||
>
|
||||
<i class="far fa-trash-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="media-library-upload" enctype="multipart/form-data">
|
||||
<div class="form-group">
|
||||
<label for="media-files">
|
||||
Upload Files
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
name="file"
|
||||
id="media-files"
|
||||
class="form-control-file"
|
||||
multiple
|
||||
/>
|
||||
<sub class="help-block">
|
||||
Attach multiple files using Control+Click or Cmd+Click.
|
||||
</sub>
|
||||
</div>
|
||||
<input type="hidden" value="page" name="type" />
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="float-right">
|
||||
<button
|
||||
@click="uploadChosenFiles"
|
||||
type="submit"
|
||||
class="btn btn-primary media-upload-button"
|
||||
>
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CTFd from "core/CTFd";
|
||||
import { ezQuery, ezToast } from "core/ezq";
|
||||
import { default as helpers } from "core/helpers";
|
||||
|
||||
function get_page_files() {
|
||||
return CTFd.fetch("/api/v1/files?type=page", {
|
||||
credentials: "same-origin"
|
||||
}).then(function(response) {
|
||||
return response.json();
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
props: {
|
||||
editor: Object
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
files: [],
|
||||
selectedFile: null
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
getPageFiles: function() {
|
||||
get_page_files().then(response => {
|
||||
this.files = response.data;
|
||||
return this.files;
|
||||
});
|
||||
},
|
||||
uploadChosenFiles: function() {
|
||||
// TODO: We should reduce the need to interact with the DOM directly.
|
||||
// This looks jank and we should be able to remove it.
|
||||
let form = document.querySelector("#media-library-upload");
|
||||
helpers.files.upload(form, {}, _data => {
|
||||
this.getPageFiles();
|
||||
});
|
||||
},
|
||||
selectFile: function(file) {
|
||||
this.selectedFile = file;
|
||||
return this.selectedFile;
|
||||
},
|
||||
buildSelectedFileUrl: function() {
|
||||
return CTFd.config.urlRoot + "/files/" + this.selectedFile.location;
|
||||
},
|
||||
deleteSelectedFile: function() {
|
||||
var file_id = this.selectedFile.id;
|
||||
|
||||
if (confirm("Are you sure you want to delete this file?")) {
|
||||
CTFd.fetch("/api/v1/files/" + file_id, {
|
||||
method: "DELETE"
|
||||
}).then(response => {
|
||||
if (response.status === 200) {
|
||||
response.json().then(object => {
|
||||
if (object.success) {
|
||||
this.getPageFiles();
|
||||
this.selectedFile = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
insertSelectedFile: function() {
|
||||
let editor = this.$props.editor;
|
||||
if (editor.hasOwnProperty("codemirror")) {
|
||||
editor = editor.codemirror;
|
||||
}
|
||||
let doc = editor.getDoc();
|
||||
let cursor = doc.getCursor();
|
||||
|
||||
let url = this.buildSelectedFileUrl();
|
||||
let img =
|
||||
this.getIconClass(this.selectedFile.location) === "far fa-file-image";
|
||||
let filename = url.split("/").pop();
|
||||
link = "[{0}]({1})".format(filename, url);
|
||||
if (img) {
|
||||
link = "!" + link;
|
||||
}
|
||||
|
||||
doc.replaceRange(link, cursor);
|
||||
},
|
||||
downloadSelectedFile: function() {
|
||||
var link = this.buildSelectedFileUrl();
|
||||
window.open(link, "_blank");
|
||||
},
|
||||
getIconClass: function(filename) {
|
||||
var mapping = {
|
||||
// Image Files
|
||||
png: "far fa-file-image",
|
||||
jpg: "far fa-file-image",
|
||||
jpeg: "far fa-file-image",
|
||||
gif: "far fa-file-image",
|
||||
bmp: "far fa-file-image",
|
||||
svg: "far fa-file-image",
|
||||
|
||||
// Text Files
|
||||
txt: "far fa-file-alt",
|
||||
|
||||
// Video Files
|
||||
mov: "far fa-file-video",
|
||||
mp4: "far fa-file-video",
|
||||
wmv: "far fa-file-video",
|
||||
flv: "far fa-file-video",
|
||||
mkv: "far fa-file-video",
|
||||
avi: "far fa-file-video",
|
||||
|
||||
// PDF Files
|
||||
pdf: "far fa-file-pdf",
|
||||
|
||||
// Audio Files
|
||||
mp3: "far fa-file-sound",
|
||||
wav: "far fa-file-sound",
|
||||
aac: "far fa-file-sound",
|
||||
|
||||
// Archive Files
|
||||
zip: "far fa-file-archive",
|
||||
gz: "far fa-file-archive",
|
||||
tar: "far fa-file-archive",
|
||||
"7z": "far fa-file-archive",
|
||||
rar: "far fa-file-archive",
|
||||
|
||||
// Code Files
|
||||
py: "far fa-file-code",
|
||||
c: "far fa-file-code",
|
||||
cpp: "far fa-file-code",
|
||||
html: "far fa-file-code",
|
||||
js: "far fa-file-code",
|
||||
rb: "far fa-file-code",
|
||||
go: "far fa-file-code"
|
||||
};
|
||||
|
||||
var ext = filename.split(".").pop();
|
||||
return mapping[ext] || "far fa-file";
|
||||
}
|
||||
},
|
||||
created() {
|
||||
return this.getPageFiles();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -5,11 +5,11 @@ import "bootstrap/js/dist/tab";
|
||||
import CTFd from "core/CTFd";
|
||||
import { htmlEntities } from "core/utils";
|
||||
import { ezQuery, ezAlert, ezToast } from "core/ezq";
|
||||
import nunjucks from "nunjucks";
|
||||
import { default as helpers } from "core/helpers";
|
||||
import { addFile, deleteFile } from "../challenges/files";
|
||||
import { addTag, deleteTag } from "../challenges/tags";
|
||||
import { addRequirement, deleteRequirement } from "../challenges/requirements";
|
||||
import { bindMarkdownEditors } from "../styles";
|
||||
import {
|
||||
showHintModal,
|
||||
editHint,
|
||||
@@ -39,7 +39,7 @@ const loadHint = id => {
|
||||
displayHint(response.data);
|
||||
return;
|
||||
}
|
||||
displayUnlock(id);
|
||||
// displayUnlock(id);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -132,42 +132,34 @@ function renderSubmissionResponse(response, cb) {
|
||||
function loadChalTemplate(challenge) {
|
||||
CTFd._internal.challenge = {};
|
||||
$.getScript(CTFd.config.urlRoot + challenge.scripts.view, function() {
|
||||
$.get(CTFd.config.urlRoot + challenge.templates.create, function(
|
||||
template_data
|
||||
) {
|
||||
const template = nunjucks.compile(template_data);
|
||||
$("#create-chal-entry-div").html(
|
||||
template.render({
|
||||
nonce: CTFd.config.csrfNonce,
|
||||
script_root: CTFd.config.urlRoot
|
||||
})
|
||||
);
|
||||
let template_data = challenge.create;
|
||||
$("#create-chal-entry-div").html(template_data);
|
||||
bindMarkdownEditors();
|
||||
|
||||
$.getScript(CTFd.config.urlRoot + challenge.scripts.create, function() {
|
||||
$("#create-chal-entry-div form").submit(function(event) {
|
||||
event.preventDefault();
|
||||
const params = $("#create-chal-entry-div form").serializeJSON();
|
||||
CTFd.fetch("/api/v1/challenges", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(params)
|
||||
$.getScript(CTFd.config.urlRoot + challenge.scripts.create, function() {
|
||||
$("#create-chal-entry-div form").submit(function(event) {
|
||||
event.preventDefault();
|
||||
const params = $("#create-chal-entry-div form").serializeJSON();
|
||||
CTFd.fetch("/api/v1/challenges", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(params)
|
||||
})
|
||||
.then(function(response) {
|
||||
return response.json();
|
||||
})
|
||||
.then(function(response) {
|
||||
return response.json();
|
||||
})
|
||||
.then(function(response) {
|
||||
if (response.success) {
|
||||
$("#challenge-create-options #challenge_id").val(
|
||||
response.data.id
|
||||
);
|
||||
$("#challenge-create-options").modal();
|
||||
}
|
||||
});
|
||||
});
|
||||
.then(function(response) {
|
||||
if (response.success) {
|
||||
$("#challenge-create-options #challenge_id").val(
|
||||
response.data.id
|
||||
);
|
||||
$("#challenge-create-options").modal();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -210,7 +202,7 @@ function handleChallengeOptions(event) {
|
||||
|
||||
Promise.all([
|
||||
// Save flag
|
||||
new Promise(function(resolve, reject) {
|
||||
new Promise(function(resolve, _reject) {
|
||||
if (flag_params.content.length == 0) {
|
||||
resolve();
|
||||
return;
|
||||
@@ -228,7 +220,7 @@ function handleChallengeOptions(event) {
|
||||
});
|
||||
}),
|
||||
// Upload files
|
||||
new Promise(function(resolve, reject) {
|
||||
new Promise(function(resolve, _reject) {
|
||||
let form = event.target;
|
||||
let data = {
|
||||
challenge: params.challenge_id,
|
||||
@@ -240,12 +232,12 @@ function handleChallengeOptions(event) {
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
]).then(responses => {
|
||||
]).then(_responses => {
|
||||
save_challenge();
|
||||
});
|
||||
}
|
||||
|
||||
function createChallenge(event) {
|
||||
function createChallenge(_event) {
|
||||
const challenge = $(this)
|
||||
.find("option:selected")
|
||||
.data("meta");
|
||||
@@ -257,113 +249,84 @@ function createChallenge(event) {
|
||||
}
|
||||
|
||||
$(() => {
|
||||
$(".preview-challenge").click(function(e) {
|
||||
$(".preview-challenge").click(function(_e) {
|
||||
window.challenge = new Object();
|
||||
CTFd._internal.challenge = {};
|
||||
$.get(CTFd.config.urlRoot + "/api/v1/challenges/" + CHALLENGE_ID, function(
|
||||
response
|
||||
) {
|
||||
const challenge = CTFd._internal.challenge;
|
||||
var challenge_data = response.data;
|
||||
challenge_data["solves"] = null;
|
||||
$.get(
|
||||
CTFd.config.urlRoot + "/api/v1/challenges/" + window.CHALLENGE_ID,
|
||||
function(response) {
|
||||
const challenge = CTFd._internal.challenge;
|
||||
var challenge_data = response.data;
|
||||
challenge_data["solves"] = null;
|
||||
|
||||
$.getScript(
|
||||
CTFd.config.urlRoot + challenge_data.type_data.scripts.view,
|
||||
function() {
|
||||
$.get(
|
||||
CTFd.config.urlRoot + challenge_data.type_data.templates.view,
|
||||
function(template_data) {
|
||||
$("#challenge-window").empty();
|
||||
var template = nunjucks.compile(template_data);
|
||||
// window.challenge.data = challenge_data;
|
||||
// window.challenge.preRender();
|
||||
challenge.data = challenge_data;
|
||||
challenge.preRender();
|
||||
$.getScript(
|
||||
CTFd.config.urlRoot + challenge_data.type_data.scripts.view,
|
||||
function() {
|
||||
$("#challenge-window").empty();
|
||||
|
||||
challenge_data["description"] = challenge.render(
|
||||
challenge_data["description"]
|
||||
);
|
||||
challenge_data["script_root"] = CTFd.config.urlRoot;
|
||||
$("#challenge-window").append(challenge_data.view);
|
||||
|
||||
$("#challenge-window").append(template.render(challenge_data));
|
||||
$("#challenge-window #challenge-input").addClass("form-control");
|
||||
$("#challenge-window #challenge-submit").addClass(
|
||||
"btn btn-md btn-outline-secondary float-right"
|
||||
);
|
||||
|
||||
$(".challenge-solves").click(function(e) {
|
||||
getsolves($("#challenge-id").val());
|
||||
});
|
||||
$(".nav-tabs a").click(function(e) {
|
||||
e.preventDefault();
|
||||
$(this).tab("show");
|
||||
});
|
||||
$(".challenge-solves").hide();
|
||||
$(".nav-tabs a").click(function(e) {
|
||||
e.preventDefault();
|
||||
$(this).tab("show");
|
||||
});
|
||||
|
||||
// Handle modal toggling
|
||||
$("#challenge-window").on("hide.bs.modal", function(event) {
|
||||
$("#submission-input").removeClass("wrong");
|
||||
$("#submission-input").removeClass("correct");
|
||||
$("#incorrect-key").slideUp();
|
||||
$("#correct-key").slideUp();
|
||||
$("#already-solved").slideUp();
|
||||
$("#too-fast").slideUp();
|
||||
});
|
||||
// Handle modal toggling
|
||||
$("#challenge-window").on("hide.bs.modal", function(_event) {
|
||||
$("#challenge-input").removeClass("wrong");
|
||||
$("#challenge-input").removeClass("correct");
|
||||
$("#incorrect-key").slideUp();
|
||||
$("#correct-key").slideUp();
|
||||
$("#already-solved").slideUp();
|
||||
$("#too-fast").slideUp();
|
||||
});
|
||||
|
||||
$(".load-hint").on("click", function(event) {
|
||||
loadHint($(this).data("hint-id"));
|
||||
});
|
||||
$(".load-hint").on("click", function(_event) {
|
||||
loadHint($(this).data("hint-id"));
|
||||
});
|
||||
|
||||
$("#submit-key").click(function(e) {
|
||||
e.preventDefault();
|
||||
$("#submit-key").addClass("disabled-button");
|
||||
$("#submit-key").prop("disabled", true);
|
||||
CTFd._internal.challenge
|
||||
.submit(true)
|
||||
.then(renderSubmissionResponse);
|
||||
// Preview passed as true
|
||||
});
|
||||
$("#challenge-submit").click(function(e) {
|
||||
e.preventDefault();
|
||||
$("#challenge-submit").addClass("disabled-button");
|
||||
$("#challenge-submit").prop("disabled", true);
|
||||
CTFd._internal.challenge
|
||||
.submit(true)
|
||||
.then(renderSubmissionResponse);
|
||||
// Preview passed as true
|
||||
});
|
||||
|
||||
$("#submission-input").keyup(function(event) {
|
||||
if (event.keyCode == 13) {
|
||||
$("#submit-key").click();
|
||||
}
|
||||
});
|
||||
$("#challenge-input").keyup(function(event) {
|
||||
if (event.keyCode == 13) {
|
||||
$("#challenge-submit").click();
|
||||
}
|
||||
});
|
||||
|
||||
$(".input-field").bind({
|
||||
focus: function() {
|
||||
$(this)
|
||||
.parent()
|
||||
.addClass("input--filled");
|
||||
$label = $(this).siblings(".input-label");
|
||||
},
|
||||
blur: function() {
|
||||
if ($(this).val() === "") {
|
||||
$(this)
|
||||
.parent()
|
||||
.removeClass("input--filled");
|
||||
$label = $(this).siblings(".input-label");
|
||||
$label.removeClass("input--hide");
|
||||
}
|
||||
}
|
||||
});
|
||||
challenge.postRender();
|
||||
window.location.replace(
|
||||
window.location.href.split("#")[0] + "#preview"
|
||||
);
|
||||
|
||||
challenge.postRender();
|
||||
window.location.replace(
|
||||
window.location.href.split("#")[0] + "#preview"
|
||||
);
|
||||
|
||||
$("#challenge-window").modal();
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
$("#challenge-window").modal();
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$(".delete-challenge").click(function(e) {
|
||||
$(".delete-challenge").click(function(_e) {
|
||||
ezQuery({
|
||||
title: "Delete Challenge",
|
||||
body: "Are you sure you want to delete {0}".format(
|
||||
"<strong>" + htmlEntities(CHALLENGE_NAME) + "</strong>"
|
||||
"<strong>" + htmlEntities(window.CHALLENGE_NAME) + "</strong>"
|
||||
),
|
||||
success: function() {
|
||||
CTFd.fetch("/api/v1/challenges/" + CHALLENGE_ID, {
|
||||
CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID, {
|
||||
method: "DELETE"
|
||||
})
|
||||
.then(function(response) {
|
||||
@@ -382,7 +345,7 @@ $(() => {
|
||||
e.preventDefault();
|
||||
var params = $(e.target).serializeJSON(true);
|
||||
|
||||
CTFd.fetch("/api/v1/challenges/" + CHALLENGE_ID + "/flags", {
|
||||
CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID + "/flags", {
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
@@ -395,7 +358,7 @@ $(() => {
|
||||
})
|
||||
.then(function(response) {
|
||||
let update_challenge = function() {
|
||||
CTFd.fetch("/api/v1/challenges/" + CHALLENGE_ID, {
|
||||
CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID, {
|
||||
method: "PATCH",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import CTFd from "core/CTFd";
|
||||
import $ from "jquery";
|
||||
import { ezAlert, ezQuery } from "core/ezq";
|
||||
|
||||
function deleteSelectedChallenges(event) {
|
||||
function deleteSelectedChallenges(_event) {
|
||||
let challengeIDs = $("input[data-challenge-id]:checked").map(function() {
|
||||
return $(this).data("challenge-id");
|
||||
});
|
||||
@@ -21,14 +21,14 @@ function deleteSelectedChallenges(event) {
|
||||
})
|
||||
);
|
||||
}
|
||||
Promise.all(reqs).then(responses => {
|
||||
Promise.all(reqs).then(_responses => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bulkEditChallenges(event) {
|
||||
function bulkEditChallenges(_event) {
|
||||
let challengeIDs = $("input[data-challenge-id]:checked").map(function() {
|
||||
return $(this).data("challenge-id");
|
||||
});
|
||||
@@ -67,7 +67,7 @@ function bulkEditChallenges(event) {
|
||||
})
|
||||
);
|
||||
}
|
||||
Promise.all(reqs).then(responses => {
|
||||
Promise.all(reqs).then(_responses => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import moment from "moment-timezone";
|
||||
import CTFd from "core/CTFd";
|
||||
import { default as helpers } from "core/helpers";
|
||||
import $ from "jquery";
|
||||
import { ezQuery, ezProgressBar } from "core/ezq";
|
||||
import { ezQuery, ezProgressBar, ezAlert } from "core/ezq";
|
||||
import CodeMirror from "codemirror";
|
||||
import "codemirror/mode/htmlmixed/htmlmixed.js";
|
||||
|
||||
@@ -110,7 +110,7 @@ function updateConfigs(event) {
|
||||
}
|
||||
});
|
||||
|
||||
CTFd.api.patch_config_list({}, params).then(response => {
|
||||
CTFd.api.patch_config_list({}, params).then(_response => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
@@ -154,7 +154,7 @@ function removeLogo() {
|
||||
};
|
||||
CTFd.api
|
||||
.patch_config({ configKey: "ctf_logo" }, params)
|
||||
.then(response => {
|
||||
.then(_response => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
@@ -182,7 +182,6 @@ function importConfig(event) {
|
||||
contentType: false,
|
||||
statusCode: {
|
||||
500: function(resp) {
|
||||
console.log(resp.responseText);
|
||||
alert(resp.responseText);
|
||||
}
|
||||
},
|
||||
@@ -199,7 +198,7 @@ function importConfig(event) {
|
||||
};
|
||||
return xhr;
|
||||
},
|
||||
success: function(data) {
|
||||
success: function(_data) {
|
||||
pg = ezProgressBar({
|
||||
target: pg,
|
||||
width: 100
|
||||
@@ -216,7 +215,6 @@ function importConfig(event) {
|
||||
|
||||
function exportConfig(event) {
|
||||
event.preventDefault();
|
||||
const href = CTFd.config.urlRoot + "/admin/export";
|
||||
window.location.href = $(this).attr("href");
|
||||
}
|
||||
|
||||
@@ -251,6 +249,52 @@ $(() => {
|
||||
}
|
||||
);
|
||||
|
||||
const theme_settings_editor = CodeMirror.fromTextArea(
|
||||
document.getElementById("theme-settings"),
|
||||
{
|
||||
lineNumbers: true,
|
||||
lineWrapping: true,
|
||||
mode: { name: "javascript", json: true }
|
||||
}
|
||||
);
|
||||
|
||||
// Handle refreshing codemirror when switching tabs.
|
||||
// Better than the autorefresh approach b/c there's no flicker
|
||||
$("a[href='#theme']").on("shown.bs.tab", function(_e) {
|
||||
theme_header_editor.refresh();
|
||||
theme_footer_editor.refresh();
|
||||
theme_settings_editor.refresh();
|
||||
});
|
||||
|
||||
$("#theme-settings-modal form").submit(function(e) {
|
||||
e.preventDefault();
|
||||
theme_settings_editor
|
||||
.getDoc()
|
||||
.setValue(JSON.stringify($(this).serializeJSON(), null, 2));
|
||||
$("#theme-settings-modal").modal("hide");
|
||||
});
|
||||
|
||||
$("#theme-settings-button").click(function() {
|
||||
let form = $("#theme-settings-modal form");
|
||||
let data = JSON.parse(theme_settings_editor.getValue());
|
||||
$.each(data, function(key, value) {
|
||||
var ctrl = form.find(`[name='${key}']`);
|
||||
switch (ctrl.prop("type")) {
|
||||
case "radio":
|
||||
case "checkbox":
|
||||
ctrl.each(function() {
|
||||
if ($(this).attr("value") == value) {
|
||||
$(this).attr("checked", value);
|
||||
}
|
||||
});
|
||||
break;
|
||||
default:
|
||||
ctrl.val(value);
|
||||
}
|
||||
});
|
||||
$("#theme-settings-modal").modal();
|
||||
});
|
||||
|
||||
insertTimezones($("#start-timezone"));
|
||||
insertTimezones($("#end-timezone"));
|
||||
insertTimezones($("#freeze-timezone"));
|
||||
|
||||
@@ -1,155 +1,11 @@
|
||||
import "./main";
|
||||
import { showMediaLibrary } from "../styles";
|
||||
import "core/utils";
|
||||
import $ from "jquery";
|
||||
import CTFd from "core/CTFd";
|
||||
import { default as helpers } from "core/helpers";
|
||||
import CodeMirror from "codemirror";
|
||||
import "codemirror/mode/htmlmixed/htmlmixed.js";
|
||||
import { ezQuery, ezToast } from "core/ezq";
|
||||
|
||||
function get_filetype_icon_class(filename) {
|
||||
var mapping = {
|
||||
// Image Files
|
||||
png: "fa-file-image",
|
||||
jpg: "fa-file-image",
|
||||
jpeg: "fa-file-image",
|
||||
gif: "fa-file-image",
|
||||
bmp: "fa-file-image",
|
||||
svg: "fa-file-image",
|
||||
|
||||
// Text Files
|
||||
txt: "fa-file-alt",
|
||||
|
||||
// Video Files
|
||||
mov: "fa-file-video",
|
||||
mp4: "fa-file-video",
|
||||
wmv: "fa-file-video",
|
||||
flv: "fa-file-video",
|
||||
mkv: "fa-file-video",
|
||||
avi: "fa-file-video",
|
||||
|
||||
// PDF Files
|
||||
pdf: "fa-file-pdf",
|
||||
|
||||
// Audio Files
|
||||
mp3: "fa-file-sound",
|
||||
wav: "fa-file-sound",
|
||||
aac: "fa-file-sound",
|
||||
|
||||
// Archive Files
|
||||
zip: "fa-file-archive",
|
||||
gz: "fa-file-archive",
|
||||
tar: "fa-file-archive",
|
||||
"7z": "fa-file-archive",
|
||||
rar: "fa-file-archive",
|
||||
|
||||
// Code Files
|
||||
py: "fa-file-code",
|
||||
c: "fa-file-code",
|
||||
cpp: "fa-file-code",
|
||||
html: "fa-file-code",
|
||||
js: "fa-file-code",
|
||||
rb: "fa-file-code",
|
||||
go: "fa-file-code"
|
||||
};
|
||||
|
||||
var ext = filename.split(".").pop();
|
||||
return mapping[ext];
|
||||
}
|
||||
|
||||
function get_page_files() {
|
||||
return CTFd.fetch("/api/v1/files?type=page", {
|
||||
credentials: "same-origin"
|
||||
}).then(function(response) {
|
||||
return response.json();
|
||||
});
|
||||
}
|
||||
|
||||
function show_files(data) {
|
||||
var list = $("#media-library-list");
|
||||
list.empty();
|
||||
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
var f = data[i];
|
||||
var fname = f.location.split("/").pop();
|
||||
var ext = get_filetype_icon_class(f.location);
|
||||
|
||||
var wrapper = $("<div>").attr("class", "media-item-wrapper");
|
||||
|
||||
var link = $("<a>");
|
||||
link.attr("href", "##");
|
||||
|
||||
if (ext === undefined) {
|
||||
link.append(
|
||||
'<i class="far fa-file" aria-hidden="true"></i> '.format(ext)
|
||||
);
|
||||
} else {
|
||||
link.append('<i class="far {0}" aria-hidden="true"></i> '.format(ext));
|
||||
}
|
||||
|
||||
link.append(
|
||||
$("<small>")
|
||||
.attr("class", "media-item-title")
|
||||
.text(fname)
|
||||
);
|
||||
|
||||
link.click(function(e) {
|
||||
var media_div = $(this).parent();
|
||||
var icon = $(this).find("i")[0];
|
||||
var f_loc = media_div.attr("data-location");
|
||||
var fname = media_div.attr("data-filename");
|
||||
var f_id = media_div.attr("data-id");
|
||||
$("#media-delete").attr("data-id", f_id);
|
||||
$("#media-link").val(f_loc);
|
||||
$("#media-filename").html(
|
||||
$("<a>")
|
||||
.attr("href", f_loc)
|
||||
.attr("target", "_blank")
|
||||
.text(fname)
|
||||
);
|
||||
|
||||
$("#media-icon").empty();
|
||||
if ($(icon).hasClass("fa-file-image")) {
|
||||
$("#media-icon").append(
|
||||
$("<img>")
|
||||
.attr("src", f_loc)
|
||||
.css({
|
||||
"max-width": "100%",
|
||||
"max-height": "100%",
|
||||
"object-fit": "contain"
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// icon is empty so we need to pull outerHTML
|
||||
var copy_icon = $(icon).clone();
|
||||
$(copy_icon).addClass("fa-4x");
|
||||
$("#media-icon").append(copy_icon);
|
||||
}
|
||||
$("#media-item").show();
|
||||
});
|
||||
wrapper.append(link);
|
||||
wrapper.attr("data-location", CTFd.config.urlRoot + "/files/" + f.location);
|
||||
wrapper.attr("data-id", f.id);
|
||||
wrapper.attr("data-filename", fname);
|
||||
list.append(wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
function refresh_files(cb) {
|
||||
get_page_files().then(function(response) {
|
||||
var data = response.data;
|
||||
show_files(data);
|
||||
if (cb) {
|
||||
cb();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function insert_at_cursor(editor, text) {
|
||||
var doc = editor.getDoc();
|
||||
var cursor = doc.getCursor();
|
||||
doc.replaceRange(text, cursor);
|
||||
}
|
||||
import { ezToast } from "core/ezq";
|
||||
|
||||
function submit_form() {
|
||||
// Save the CodeMirror data to the Textarea
|
||||
@@ -158,8 +14,9 @@ function submit_form() {
|
||||
var target = "/api/v1/pages";
|
||||
var method = "POST";
|
||||
|
||||
if (params.id) {
|
||||
target += "/" + params.id;
|
||||
let part = window.location.pathname.split("/").pop();
|
||||
if (part !== "new") {
|
||||
target += "/" + part;
|
||||
method = "PATCH";
|
||||
}
|
||||
|
||||
@@ -189,18 +46,12 @@ function submit_form() {
|
||||
}
|
||||
|
||||
function preview_page() {
|
||||
editor.save(); // Save the CodeMirror data to the Textarea
|
||||
window.editor.save(); // Save the CodeMirror data to the Textarea
|
||||
$("#page-edit").attr("action", CTFd.config.urlRoot + "/admin/pages/preview");
|
||||
$("#page-edit").attr("target", "_blank");
|
||||
$("#page-edit").submit();
|
||||
}
|
||||
|
||||
function upload_media() {
|
||||
helpers.files.upload($("#media-library-upload"), {}, function(data) {
|
||||
refresh_files();
|
||||
});
|
||||
}
|
||||
|
||||
$(() => {
|
||||
window.editor = CodeMirror.fromTextArea(
|
||||
document.getElementById("admin-pages-editor"),
|
||||
@@ -212,55 +63,8 @@ $(() => {
|
||||
}
|
||||
);
|
||||
|
||||
$("#media-insert").click(function(e) {
|
||||
var tag = "";
|
||||
try {
|
||||
tag = $("#media-icon")
|
||||
.children()[0]
|
||||
.nodeName.toLowerCase();
|
||||
} catch (err) {
|
||||
tag = "";
|
||||
}
|
||||
var link = $("#media-link").val();
|
||||
var fname = $("#media-filename").text();
|
||||
var entry = null;
|
||||
if (tag === "img") {
|
||||
entry = "".format(fname, link);
|
||||
} else {
|
||||
entry = "[{0}]({1})".format(fname, link);
|
||||
}
|
||||
insert_at_cursor(editor, entry);
|
||||
});
|
||||
|
||||
$("#media-download").click(function(e) {
|
||||
var link = $("#media-link").val();
|
||||
window.open(link, "_blank");
|
||||
});
|
||||
|
||||
$("#media-delete").click(function(e) {
|
||||
var file_id = $(this).attr("data-id");
|
||||
ezQuery({
|
||||
title: "Delete File?",
|
||||
body: "Are you sure you want to delete this file?",
|
||||
success: function() {
|
||||
CTFd.fetch("/api/v1/files/" + file_id, {
|
||||
method: "DELETE",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
}).then(function(response) {
|
||||
if (response.status === 200) {
|
||||
response.json().then(function(object) {
|
||||
if (object.success) {
|
||||
refresh_files();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
$("#media-button").click(function(_e) {
|
||||
showMediaLibrary(window.editor);
|
||||
});
|
||||
|
||||
$("#save-page").click(function(e) {
|
||||
@@ -268,17 +72,6 @@ $(() => {
|
||||
submit_form();
|
||||
});
|
||||
|
||||
$("#media-button").click(function() {
|
||||
$("#media-library-list").empty();
|
||||
refresh_files(function() {
|
||||
$("#media-modal").modal();
|
||||
});
|
||||
});
|
||||
|
||||
$(".media-upload-button").click(function() {
|
||||
upload_media();
|
||||
});
|
||||
|
||||
$(".preview-page").click(function() {
|
||||
preview_page();
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import CTFd from "core/CTFd";
|
||||
import $ from "jquery";
|
||||
import { ezQuery } from "core/ezq";
|
||||
|
||||
function deleteSelectedUsers(event) {
|
||||
function deleteSelectedUsers(_event) {
|
||||
let pageIDs = $("input[data-page-id]:checked").map(function() {
|
||||
return $(this).data("page-id");
|
||||
});
|
||||
@@ -21,7 +21,7 @@ function deleteSelectedUsers(event) {
|
||||
})
|
||||
);
|
||||
}
|
||||
Promise.all(reqs).then(responses => {
|
||||
Promise.all(reqs).then(_responses => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "./main";
|
||||
import CTFd from "core/CTFd";
|
||||
import $ from "jquery";
|
||||
import { ezAlert, ezQuery } from "core/ezq";
|
||||
import { ezAlert } from "core/ezq";
|
||||
|
||||
const api_func = {
|
||||
users: (x, y) => CTFd.api.patch_user_public({ userId: x }, y),
|
||||
@@ -46,12 +46,12 @@ function toggleSelectedAccounts(accountIDs, action) {
|
||||
for (var accId of accountIDs) {
|
||||
reqs.push(api_func[CTFd.config.userMode](accId, params));
|
||||
}
|
||||
Promise.all(reqs).then(responses => {
|
||||
Promise.all(reqs).then(_responses => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
function bulkToggleAccounts(event) {
|
||||
function bulkToggleAccounts(_event) {
|
||||
let accountIDs = $("input[data-account-id]:checked").map(function() {
|
||||
return $(this).data("account-id");
|
||||
});
|
||||
|
||||
@@ -2,28 +2,16 @@ import "./main";
|
||||
import "core/utils";
|
||||
import CTFd from "core/CTFd";
|
||||
import $ from "jquery";
|
||||
import Plotly from "plotly.js-basic-dist";
|
||||
import { createGraph, updateGraph } from "core/graphs";
|
||||
import echarts from "echarts/dist/echarts-en.common";
|
||||
import { colorHash } from "core/utils";
|
||||
|
||||
const graph_configs = {
|
||||
"#solves-graph": {
|
||||
layout: annotations => ({
|
||||
title: "Solve Counts",
|
||||
annotations: annotations,
|
||||
xaxis: {
|
||||
title: "Challenge Name"
|
||||
},
|
||||
yaxis: {
|
||||
title: "Amount of Solves"
|
||||
}
|
||||
}),
|
||||
fn: () => "CTFd_solves_" + new Date().toISOString().slice(0, 19),
|
||||
data: () => CTFd.api.get_challenge_solve_statistics(),
|
||||
format: response => {
|
||||
const data = response.data;
|
||||
const chals = [];
|
||||
const counts = [];
|
||||
const annotations = [];
|
||||
const solves = {};
|
||||
for (let c = 0; c < data.length; c++) {
|
||||
solves[data[c]["id"]] = {
|
||||
@@ -39,63 +27,155 @@ const graph_configs = {
|
||||
$.each(solves_order, function(key, value) {
|
||||
chals.push(solves[value].name);
|
||||
counts.push(solves[value].solves);
|
||||
const result = {
|
||||
x: solves[value].name,
|
||||
y: solves[value].solves,
|
||||
text: solves[value].solves,
|
||||
xanchor: "center",
|
||||
yanchor: "bottom",
|
||||
showarrow: false
|
||||
};
|
||||
annotations.push(result);
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
type: "bar",
|
||||
x: chals,
|
||||
y: counts,
|
||||
text: counts,
|
||||
orientation: "v"
|
||||
const option = {
|
||||
title: {
|
||||
left: "center",
|
||||
text: "Solve Counts"
|
||||
},
|
||||
annotations
|
||||
];
|
||||
tooltip: {
|
||||
trigger: "item"
|
||||
},
|
||||
toolbox: {
|
||||
show: true,
|
||||
feature: {
|
||||
mark: { show: true },
|
||||
dataView: { show: true, readOnly: false },
|
||||
magicType: { show: true, type: ["line", "bar"] },
|
||||
restore: { show: true },
|
||||
saveAsImage: { show: true }
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
name: "Solve Count",
|
||||
nameLocation: "middle",
|
||||
type: "value"
|
||||
},
|
||||
yAxis: {
|
||||
name: "Challenge Name",
|
||||
nameLocation: "middle",
|
||||
nameGap: 60,
|
||||
type: "category",
|
||||
data: chals,
|
||||
axisLabel: {
|
||||
interval: 0,
|
||||
rotate: 0 //If the label names are too long you can manage this by rotating the label.
|
||||
}
|
||||
},
|
||||
dataZoom: [
|
||||
{
|
||||
id: "dataZoomY",
|
||||
type: "slider",
|
||||
yAxisIndex: [0],
|
||||
filterMode: "empty"
|
||||
}
|
||||
],
|
||||
series: [
|
||||
{
|
||||
itemStyle: { normal: { color: "#1f76b4" } },
|
||||
data: counts,
|
||||
type: "bar"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return option;
|
||||
}
|
||||
},
|
||||
|
||||
"#keys-pie-graph": {
|
||||
layout: () => ({
|
||||
title: "Submission Percentages"
|
||||
}),
|
||||
fn: () => "CTFd_submissions_" + new Date().toISOString().slice(0, 19),
|
||||
data: () => CTFd.api.get_submission_property_counts({ column: "type" }),
|
||||
format: response => {
|
||||
const data = response.data;
|
||||
const solves = data["correct"];
|
||||
const fails = data["incorrect"];
|
||||
|
||||
return [
|
||||
{
|
||||
values: [solves, fails],
|
||||
labels: ["Correct", "Incorrect"],
|
||||
marker: {
|
||||
colors: ["rgb(0, 209, 64)", "rgb(207, 38, 0)"]
|
||||
},
|
||||
text: ["Solves", "Fails"],
|
||||
hole: 0.4,
|
||||
type: "pie"
|
||||
let option = {
|
||||
title: {
|
||||
left: "center",
|
||||
text: "Submission Percentages"
|
||||
},
|
||||
null
|
||||
];
|
||||
tooltip: {
|
||||
trigger: "item"
|
||||
},
|
||||
toolbox: {
|
||||
show: true,
|
||||
feature: {
|
||||
dataView: { show: true, readOnly: false },
|
||||
saveAsImage: {}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
orient: "horizontal",
|
||||
bottom: 0,
|
||||
data: ["Fails", "Solves"]
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: "Submission Percentages",
|
||||
type: "pie",
|
||||
radius: ["30%", "50%"],
|
||||
avoidLabelOverlap: false,
|
||||
label: {
|
||||
show: false,
|
||||
position: "center"
|
||||
},
|
||||
itemStyle: {
|
||||
normal: {
|
||||
label: {
|
||||
show: true,
|
||||
formatter: function(data) {
|
||||
return `${data.name} - ${data.value} (${data.percent}%)`;
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: true
|
||||
}
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
position: "center",
|
||||
textStyle: {
|
||||
fontSize: "14",
|
||||
fontWeight: "normal"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: "30",
|
||||
fontWeight: "bold"
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: fails,
|
||||
name: "Fails",
|
||||
itemStyle: { color: "rgb(207, 38, 0)" }
|
||||
},
|
||||
{
|
||||
value: solves,
|
||||
name: "Solves",
|
||||
itemStyle: { color: "rgb(0, 209, 64)" }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return option;
|
||||
}
|
||||
},
|
||||
|
||||
"#categories-pie-graph": {
|
||||
layout: () => ({
|
||||
title: "Category Breakdown"
|
||||
}),
|
||||
data: () => CTFd.api.get_challenge_property_counts({ column: "category" }),
|
||||
fn: () => "CTFd_categories_" + new Date().toISOString().slice(0, 19),
|
||||
format: response => {
|
||||
const data = response.data;
|
||||
|
||||
@@ -114,15 +194,84 @@ const graph_configs = {
|
||||
count.push(data[i].count);
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
values: count,
|
||||
labels: categories,
|
||||
hole: 0.4,
|
||||
type: "pie"
|
||||
let option = {
|
||||
title: {
|
||||
left: "center",
|
||||
text: "Category Breakdown"
|
||||
},
|
||||
null
|
||||
];
|
||||
tooltip: {
|
||||
trigger: "item"
|
||||
},
|
||||
toolbox: {
|
||||
show: true,
|
||||
feature: {
|
||||
dataView: { show: true, readOnly: false },
|
||||
saveAsImage: {}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
orient: "horizontal",
|
||||
bottom: 0,
|
||||
data: []
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: "Category Breakdown",
|
||||
type: "pie",
|
||||
radius: ["30%", "50%"],
|
||||
avoidLabelOverlap: false,
|
||||
label: {
|
||||
show: false,
|
||||
position: "center"
|
||||
},
|
||||
itemStyle: {
|
||||
normal: {
|
||||
label: {
|
||||
show: true,
|
||||
formatter: function(data) {
|
||||
return `${data.name} - ${data.value} (${data.percent}%)`;
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: true
|
||||
}
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
position: "center",
|
||||
textStyle: {
|
||||
fontSize: "14",
|
||||
fontWeight: "normal"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: "30",
|
||||
fontWeight: "bold"
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: []
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
categories.forEach((category, index) => {
|
||||
option.legend.data.push(category);
|
||||
option.series[0].data.push({
|
||||
value: count[index],
|
||||
name: category,
|
||||
itemStyle: { color: colorHash(category) }
|
||||
});
|
||||
});
|
||||
|
||||
return option;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -142,8 +291,6 @@ const graph_configs = {
|
||||
annotations: annotations
|
||||
}),
|
||||
data: () => CTFd.api.get_challenge_solve_percentages(),
|
||||
fn: () =>
|
||||
"CTFd_challenge_percentages_" + new Date().toISOString().slice(0, 19),
|
||||
format: response => {
|
||||
const data = response.data;
|
||||
|
||||
@@ -167,15 +314,61 @@ const graph_configs = {
|
||||
annotations.push(result);
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
type: "bar",
|
||||
x: names,
|
||||
y: percents,
|
||||
orientation: "v"
|
||||
const option = {
|
||||
title: {
|
||||
left: "center",
|
||||
text: "Solve Percentages per Challenge"
|
||||
},
|
||||
annotations
|
||||
];
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
formatter: function(data) {
|
||||
return `${data.name} - ${(Math.round(data.value * 10) / 10).toFixed(
|
||||
1
|
||||
)}%`;
|
||||
}
|
||||
},
|
||||
toolbox: {
|
||||
show: true,
|
||||
feature: {
|
||||
mark: { show: true },
|
||||
dataView: { show: true, readOnly: false },
|
||||
magicType: { show: true, type: ["line", "bar"] },
|
||||
restore: { show: true },
|
||||
saveAsImage: { show: true }
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
name: "Challenge Name",
|
||||
nameGap: 40,
|
||||
nameLocation: "middle",
|
||||
type: "category",
|
||||
data: names,
|
||||
axisLabel: {
|
||||
interval: 0,
|
||||
rotate: 50
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
name: "Percentage of {0} (%)".format(
|
||||
CTFd.config.userMode.charAt(0).toUpperCase() +
|
||||
CTFd.config.userMode.slice(1)
|
||||
),
|
||||
nameGap: 50,
|
||||
nameLocation: "middle",
|
||||
type: "value",
|
||||
min: 0,
|
||||
max: 100
|
||||
},
|
||||
series: [
|
||||
{
|
||||
itemStyle: { normal: { color: "#1f76b4" } },
|
||||
data: percents,
|
||||
type: "bar"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return option;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -201,8 +394,6 @@ const graph_configs = {
|
||||
) {
|
||||
return response.json();
|
||||
}),
|
||||
fn: () =>
|
||||
"CTFd_score_distribution_" + new Date().toISOString().slice(0, 19),
|
||||
format: response => {
|
||||
const data = response.data.brackets;
|
||||
const keys = [];
|
||||
@@ -221,36 +412,73 @@ const graph_configs = {
|
||||
start = key;
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
type: "bar",
|
||||
x: brackets,
|
||||
y: sizes,
|
||||
orientation: "v"
|
||||
}
|
||||
];
|
||||
const option = {
|
||||
title: {
|
||||
left: "center",
|
||||
text: "Score Distribution"
|
||||
},
|
||||
tooltip: {
|
||||
trigger: "item"
|
||||
},
|
||||
toolbox: {
|
||||
show: true,
|
||||
feature: {
|
||||
mark: { show: true },
|
||||
dataView: { show: true, readOnly: false },
|
||||
magicType: { show: true, type: ["line", "bar"] },
|
||||
restore: { show: true },
|
||||
saveAsImage: { show: true }
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
name: "Score Bracket",
|
||||
nameGap: 40,
|
||||
nameLocation: "middle",
|
||||
type: "category",
|
||||
data: brackets
|
||||
},
|
||||
yAxis: {
|
||||
name: "Number of {0}".format(
|
||||
CTFd.config.userMode.charAt(0).toUpperCase() +
|
||||
CTFd.config.userMode.slice(1)
|
||||
),
|
||||
nameGap: 50,
|
||||
nameLocation: "middle",
|
||||
type: "value"
|
||||
},
|
||||
series: [
|
||||
{
|
||||
itemStyle: { normal: { color: "#1f76b4" } },
|
||||
data: sizes,
|
||||
type: "bar"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return option;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const config = {
|
||||
displaylogo: false,
|
||||
responsive: true
|
||||
};
|
||||
|
||||
const createGraphs = () => {
|
||||
for (let key in graph_configs) {
|
||||
const cfg = graph_configs[key];
|
||||
|
||||
const $elem = $(key);
|
||||
$elem.empty();
|
||||
$elem[0].fn = cfg.fn();
|
||||
|
||||
let chart = echarts.init(document.querySelector(key));
|
||||
|
||||
cfg
|
||||
.data()
|
||||
.then(cfg.format)
|
||||
.then(([data, annotations]) => {
|
||||
Plotly.newPlot($elem[0], [data], cfg.layout(annotations), config);
|
||||
.then(option => {
|
||||
chart.setOption(option);
|
||||
$(window).on("resize", function() {
|
||||
if (chart != null && chart != undefined) {
|
||||
chart.resize();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -258,13 +486,12 @@ const createGraphs = () => {
|
||||
function updateGraphs() {
|
||||
for (let key in graph_configs) {
|
||||
const cfg = graph_configs[key];
|
||||
const $elem = $(key);
|
||||
let chart = echarts.init(document.querySelector(key));
|
||||
cfg
|
||||
.data()
|
||||
.then(cfg.format)
|
||||
.then(([data, annotations]) => {
|
||||
// FIXME: Pass annotations
|
||||
Plotly.react($elem[0], [data], cfg.layout(annotations), config);
|
||||
.then(option => {
|
||||
chart.setOption(option);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import $ from "jquery";
|
||||
import { htmlEntities } from "core/utils";
|
||||
import { ezQuery } from "core/ezq";
|
||||
|
||||
function deleteCorrectSubmission(event) {
|
||||
function deleteCorrectSubmission(_event) {
|
||||
const key_id = $(this).data("submission-id");
|
||||
const $elem = $(this)
|
||||
.parent()
|
||||
@@ -40,7 +40,7 @@ function deleteCorrectSubmission(event) {
|
||||
});
|
||||
}
|
||||
|
||||
function deleteSelectedSubmissions(event) {
|
||||
function deleteSelectedSubmissions(_event) {
|
||||
let submissionIDs = $("input[data-submission-id]:checked").map(function() {
|
||||
return $(this).data("submission-id");
|
||||
});
|
||||
@@ -54,7 +54,7 @@ function deleteSelectedSubmissions(event) {
|
||||
for (var subId of submissionIDs) {
|
||||
reqs.push(CTFd.api.delete_submission({ submissionId: subId }));
|
||||
}
|
||||
Promise.all(reqs).then(responses => {
|
||||
Promise.all(reqs).then(_responses => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ function createTeam(event) {
|
||||
window.location = CTFd.config.urlRoot + "/admin/teams/" + team_id;
|
||||
} else {
|
||||
$("#team-info-form > #results").empty();
|
||||
Object.keys(response.errors).forEach(function(key, index) {
|
||||
Object.keys(response.errors).forEach(function(key, _index) {
|
||||
$("#team-info-form > #results").append(
|
||||
ezBadge({
|
||||
type: "error",
|
||||
@@ -47,7 +47,7 @@ function updateTeam(event) {
|
||||
event.preventDefault();
|
||||
const params = $("#team-info-edit-form").serializeJSON(true);
|
||||
|
||||
CTFd.fetch("/api/v1/teams/" + TEAM_ID, {
|
||||
CTFd.fetch("/api/v1/teams/" + window.TEAM_ID, {
|
||||
method: "PATCH",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
@@ -64,7 +64,7 @@ function updateTeam(event) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
$("#team-info-form > #results").empty();
|
||||
Object.keys(response.errors).forEach(function(key, index) {
|
||||
Object.keys(response.errors).forEach(function(key, _index) {
|
||||
$("#team-info-form > #results").append(
|
||||
ezBadge({
|
||||
type: "error",
|
||||
@@ -114,14 +114,14 @@ function deleteSelectedSubmissions(event, target) {
|
||||
for (var subId of submissionIDs) {
|
||||
reqs.push(CTFd.api.delete_submission({ submissionId: subId }));
|
||||
}
|
||||
Promise.all(reqs).then(responses => {
|
||||
Promise.all(reqs).then(_responses => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function deleteSelectedAwards(event) {
|
||||
function deleteSelectedAwards(_event) {
|
||||
let awardIDs = $("input[data-award-id]:checked").map(function() {
|
||||
return $(this).data("award-id");
|
||||
});
|
||||
@@ -143,7 +143,7 @@ function deleteSelectedAwards(event) {
|
||||
});
|
||||
reqs.push(req);
|
||||
}
|
||||
Promise.all(reqs).then(responses => {
|
||||
Promise.all(reqs).then(_responses => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
@@ -163,12 +163,12 @@ function solveSelectedMissingChallenges(event) {
|
||||
title: `Mark Correct`,
|
||||
body: `Are you sure you want to mark ${
|
||||
challengeIDs.length
|
||||
} challenges correct for ${htmlEntities(TEAM_NAME)}?`,
|
||||
} ${target} correct for ${htmlEntities(window.TEAM_NAME)}?`,
|
||||
success: function() {
|
||||
ezAlert({
|
||||
title: `User Attribution`,
|
||||
body: `
|
||||
Which user on ${htmlEntities(TEAM_NAME)} solved these challenges?
|
||||
Which user on ${htmlEntities(window.TEAM_NAME)} solved these challenges?
|
||||
<div class="pb-3" id="query-team-member-solve">
|
||||
${$("#team-member-select").html()}
|
||||
</div>
|
||||
@@ -181,7 +181,7 @@ function solveSelectedMissingChallenges(event) {
|
||||
let params = {
|
||||
provided: "MARKED AS SOLVED BY ADMIN",
|
||||
user_id: USER_ID,
|
||||
team_id: TEAM_ID,
|
||||
team_id: window.TEAM_ID,
|
||||
challenge_id: challengeID,
|
||||
type: "correct"
|
||||
};
|
||||
@@ -197,7 +197,7 @@ function solveSelectedMissingChallenges(event) {
|
||||
});
|
||||
reqs.push(req);
|
||||
}
|
||||
Promise.all(reqs).then(responses => {
|
||||
Promise.all(reqs).then(_responses => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
@@ -300,7 +300,7 @@ $(() => {
|
||||
e.preventDefault();
|
||||
const params = $("#team-captain-form").serializeJSON(true);
|
||||
|
||||
CTFd.fetch("/api/v1/teams/" + TEAM_ID, {
|
||||
CTFd.fetch("/api/v1/teams/" + window.TEAM_ID, {
|
||||
method: "PATCH",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
@@ -317,7 +317,7 @@ $(() => {
|
||||
window.location.reload();
|
||||
} else {
|
||||
$("#team-captain-form > #results").empty();
|
||||
Object.keys(response.errors).forEach(function(key, index) {
|
||||
Object.keys(response.errors).forEach(function(key, _index) {
|
||||
$("#team-captain-form > #results").append(
|
||||
ezBadge({
|
||||
type: "error",
|
||||
@@ -335,19 +335,19 @@ $(() => {
|
||||
});
|
||||
});
|
||||
|
||||
$(".edit-team").click(function(e) {
|
||||
$(".edit-team").click(function(_e) {
|
||||
$("#team-info-edit-modal").modal("toggle");
|
||||
});
|
||||
|
||||
$(".edit-captain").click(function(e) {
|
||||
$(".edit-captain").click(function(_e) {
|
||||
$("#team-captain-modal").modal("toggle");
|
||||
});
|
||||
|
||||
$(".award-team").click(function(e) {
|
||||
$(".award-team").click(function(_e) {
|
||||
$("#team-award-modal").modal("toggle");
|
||||
});
|
||||
|
||||
$(".addresses-team").click(function(event) {
|
||||
$(".addresses-team").click(function(_event) {
|
||||
$("#team-addresses-modal").modal("toggle");
|
||||
});
|
||||
|
||||
@@ -355,7 +355,7 @@ $(() => {
|
||||
e.preventDefault();
|
||||
const params = $("#user-award-form").serializeJSON(true);
|
||||
params["user_id"] = $("#award-member-input").val();
|
||||
params["team_id"] = TEAM_ID;
|
||||
params["team_id"] = window.TEAM_ID;
|
||||
|
||||
$("#user-award-form > #results").empty();
|
||||
|
||||
@@ -387,7 +387,7 @@ $(() => {
|
||||
window.location.reload();
|
||||
} else {
|
||||
$("#user-award-form > #results").empty();
|
||||
Object.keys(response.errors).forEach(function(key, index) {
|
||||
Object.keys(response.errors).forEach(function(key, _index) {
|
||||
$("#user-award-form > #results").append(
|
||||
ezBadge({
|
||||
type: "error",
|
||||
@@ -420,10 +420,10 @@ $(() => {
|
||||
title: "Remove Member",
|
||||
body: "Are you sure you want to remove {0} from {1}? <br><br><strong>All of their challenges solves, attempts, awards, and unlocked hints will also be deleted!</strong>".format(
|
||||
"<strong>" + htmlEntities(member_name) + "</strong>",
|
||||
"<strong>" + htmlEntities(TEAM_NAME) + "</strong>"
|
||||
"<strong>" + htmlEntities(window.TEAM_NAME) + "</strong>"
|
||||
),
|
||||
success: function() {
|
||||
CTFd.fetch("/api/v1/teams/" + TEAM_ID + "/members", {
|
||||
CTFd.fetch("/api/v1/teams/" + window.TEAM_ID + "/members", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify(params)
|
||||
})
|
||||
@@ -439,14 +439,14 @@ $(() => {
|
||||
});
|
||||
});
|
||||
|
||||
$(".delete-team").click(function(e) {
|
||||
$(".delete-team").click(function(_e) {
|
||||
ezQuery({
|
||||
title: "Delete Team",
|
||||
body: "Are you sure you want to delete {0}".format(
|
||||
"<strong>" + htmlEntities(TEAM_NAME) + "</strong>"
|
||||
"<strong>" + htmlEntities(window.TEAM_NAME) + "</strong>"
|
||||
),
|
||||
success: function() {
|
||||
CTFd.fetch("/api/v1/teams/" + TEAM_ID, {
|
||||
CTFd.fetch("/api/v1/teams/" + window.TEAM_ID, {
|
||||
method: "DELETE"
|
||||
})
|
||||
.then(function(response) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import CTFd from "core/CTFd";
|
||||
import $ from "jquery";
|
||||
import { ezAlert, ezQuery } from "core/ezq";
|
||||
|
||||
function deleteSelectedTeams(event) {
|
||||
function deleteSelectedTeams(_event) {
|
||||
let teamIDs = $("input[data-team-id]:checked").map(function() {
|
||||
return $(this).data("team-id");
|
||||
});
|
||||
@@ -21,14 +21,14 @@ function deleteSelectedTeams(event) {
|
||||
})
|
||||
);
|
||||
}
|
||||
Promise.all(reqs).then(responses => {
|
||||
Promise.all(reqs).then(_responses => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bulkEditTeams(event) {
|
||||
function bulkEditTeams(_event) {
|
||||
let teamIDs = $("input[data-team-id]:checked").map(function() {
|
||||
return $(this).data("team-id");
|
||||
});
|
||||
@@ -67,7 +67,7 @@ function bulkEditTeams(event) {
|
||||
})
|
||||
);
|
||||
}
|
||||
Promise.all(reqs).then(responses => {
|
||||
Promise.all(reqs).then(_responses => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ function createUser(event) {
|
||||
window.location = CTFd.config.urlRoot + "/admin/users/" + user_id;
|
||||
} else {
|
||||
$("#user-info-create-form > #results").empty();
|
||||
Object.keys(response.errors).forEach(function(key, index) {
|
||||
Object.keys(response.errors).forEach(function(key, _index) {
|
||||
$("#user-info-create-form > #results").append(
|
||||
ezBadge({
|
||||
type: "error",
|
||||
@@ -55,7 +55,7 @@ function updateUser(event) {
|
||||
event.preventDefault();
|
||||
const params = $("#user-info-edit-form").serializeJSON(true);
|
||||
|
||||
CTFd.fetch("/api/v1/users/" + USER_ID, {
|
||||
CTFd.fetch("/api/v1/users/" + window.USER_ID, {
|
||||
method: "PATCH",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
@@ -72,7 +72,7 @@ function updateUser(event) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
$("#user-info-edit-form > #results").empty();
|
||||
Object.keys(response.errors).forEach(function(key, index) {
|
||||
Object.keys(response.errors).forEach(function(key, _index) {
|
||||
$("#user-info-edit-form > #results").append(
|
||||
ezBadge({
|
||||
type: "error",
|
||||
@@ -95,10 +95,10 @@ function deleteUser(event) {
|
||||
ezQuery({
|
||||
title: "Delete User",
|
||||
body: "Are you sure you want to delete {0}".format(
|
||||
"<strong>" + htmlEntities(USER_NAME) + "</strong>"
|
||||
"<strong>" + htmlEntities(window.USER_NAME) + "</strong>"
|
||||
),
|
||||
success: function() {
|
||||
CTFd.fetch("/api/v1/users/" + USER_ID, {
|
||||
CTFd.fetch("/api/v1/users/" + window.USER_ID, {
|
||||
method: "DELETE"
|
||||
})
|
||||
.then(function(response) {
|
||||
@@ -116,7 +116,7 @@ function deleteUser(event) {
|
||||
function awardUser(event) {
|
||||
event.preventDefault();
|
||||
const params = $("#user-award-form").serializeJSON(true);
|
||||
params["user_id"] = USER_ID;
|
||||
params["user_id"] = window.USER_ID;
|
||||
|
||||
CTFd.fetch("/api/v1/awards", {
|
||||
method: "POST",
|
||||
@@ -135,7 +135,7 @@ function awardUser(event) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
$("#user-award-form > #results").empty();
|
||||
Object.keys(response.errors).forEach(function(key, index) {
|
||||
Object.keys(response.errors).forEach(function(key, _index) {
|
||||
$("#user-award-form > #results").append(
|
||||
ezBadge({
|
||||
type: "error",
|
||||
@@ -154,7 +154,7 @@ function awardUser(event) {
|
||||
function emailUser(event) {
|
||||
event.preventDefault();
|
||||
var params = $("#user-mail-form").serializeJSON(true);
|
||||
CTFd.fetch("/api/v1/users/" + USER_ID + "/email", {
|
||||
CTFd.fetch("/api/v1/users/" + window.USER_ID + "/email", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
@@ -179,7 +179,7 @@ function emailUser(event) {
|
||||
.val("");
|
||||
} else {
|
||||
$("#user-mail-form > #results").empty();
|
||||
Object.keys(response.errors).forEach(function(key, index) {
|
||||
Object.keys(response.errors).forEach(function(key, _index) {
|
||||
$("#user-mail-form > #results").append(
|
||||
ezBadge({
|
||||
type: "error",
|
||||
@@ -231,14 +231,14 @@ function deleteSelectedSubmissions(event, target) {
|
||||
for (var subId of submissionIDs) {
|
||||
reqs.push(CTFd.api.delete_submission({ submissionId: subId }));
|
||||
}
|
||||
Promise.all(reqs).then(responses => {
|
||||
Promise.all(reqs).then(_responses => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function deleteSelectedAwards(event) {
|
||||
function deleteSelectedAwards(_event) {
|
||||
let awardIDs = $("input[data-award-id]:checked").map(function() {
|
||||
return $(this).data("award-id");
|
||||
});
|
||||
@@ -260,7 +260,7 @@ function deleteSelectedAwards(event) {
|
||||
});
|
||||
reqs.push(req);
|
||||
}
|
||||
Promise.all(reqs).then(responses => {
|
||||
Promise.all(reqs).then(_responses => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
@@ -280,14 +280,14 @@ function solveSelectedMissingChallenges(event) {
|
||||
title: `Mark Correct`,
|
||||
body: `Are you sure you want to mark ${
|
||||
challengeIDs.length
|
||||
} correct for ${htmlEntities(USER_NAME)}?`,
|
||||
} ${target} correct for ${htmlEntities(window.USER_NAME)}?`,
|
||||
success: function() {
|
||||
const reqs = [];
|
||||
for (var challengeID of challengeIDs) {
|
||||
let params = {
|
||||
provided: "MARKED AS SOLVED BY ADMIN",
|
||||
user_id: USER_ID,
|
||||
team_id: TEAM_ID,
|
||||
user_id: window.USER_ID,
|
||||
team_id: window.TEAM_ID,
|
||||
challenge_id: challengeID,
|
||||
type: "correct"
|
||||
};
|
||||
@@ -303,7 +303,7 @@ function solveSelectedMissingChallenges(event) {
|
||||
});
|
||||
reqs.push(req);
|
||||
}
|
||||
Promise.all(reqs).then(responses => {
|
||||
Promise.all(reqs).then(_responses => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
@@ -402,19 +402,19 @@ const updateGraphs = (type, id, name, account_id) => {
|
||||
$(() => {
|
||||
$(".delete-user").click(deleteUser);
|
||||
|
||||
$(".edit-user").click(function(event) {
|
||||
$(".edit-user").click(function(_event) {
|
||||
$("#user-info-modal").modal("toggle");
|
||||
});
|
||||
|
||||
$(".award-user").click(function(event) {
|
||||
$(".award-user").click(function(_event) {
|
||||
$("#user-award-modal").modal("toggle");
|
||||
});
|
||||
|
||||
$(".email-user").click(function(event) {
|
||||
$(".email-user").click(function(_event) {
|
||||
$("#user-email-modal").modal("toggle");
|
||||
});
|
||||
|
||||
$(".addresses-user").click(function(event) {
|
||||
$(".addresses-user").click(function(_event) {
|
||||
$("#user-addresses-modal").modal("toggle");
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import CTFd from "core/CTFd";
|
||||
import $ from "jquery";
|
||||
import { ezAlert, ezQuery } from "core/ezq";
|
||||
|
||||
function deleteSelectedUsers(event) {
|
||||
function deleteSelectedUsers(_event) {
|
||||
let userIDs = $("input[data-user-id]:checked").map(function() {
|
||||
return $(this).data("user-id");
|
||||
});
|
||||
@@ -21,14 +21,14 @@ function deleteSelectedUsers(event) {
|
||||
})
|
||||
);
|
||||
}
|
||||
Promise.all(reqs).then(responses => {
|
||||
Promise.all(reqs).then(_responses => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bulkEditUsers(event) {
|
||||
function bulkEditUsers(_event) {
|
||||
let userIDs = $("input[data-user-id]:checked").map(function() {
|
||||
return $(this).data("user-id");
|
||||
});
|
||||
@@ -75,7 +75,7 @@ function bulkEditUsers(event) {
|
||||
})
|
||||
);
|
||||
}
|
||||
Promise.all(reqs).then(responses => {
|
||||
Promise.all(reqs).then(_responses => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,77 @@
|
||||
import "bootstrap/dist/js/bootstrap.bundle";
|
||||
import { makeSortableTables } from "core/utils";
|
||||
import $ from "jquery";
|
||||
import EasyMDE from "easymde";
|
||||
import Vue from "vue/dist/vue.esm.browser";
|
||||
import MediaLibrary from "./components/files/MediaLibrary.vue";
|
||||
|
||||
export function showMediaLibrary(editor) {
|
||||
const mediaModal = Vue.extend(MediaLibrary);
|
||||
|
||||
// Create an empty div and append it to our <main>
|
||||
let vueContainer = document.createElement("div");
|
||||
document.querySelector("main").appendChild(vueContainer);
|
||||
|
||||
// Create MediaLibrary component and pass it our editor
|
||||
let m = new mediaModal({
|
||||
propsData: {
|
||||
editor: editor
|
||||
}
|
||||
// Mount to the empty div
|
||||
}).$mount(vueContainer);
|
||||
|
||||
// Destroy the Vue instance and the media modal when closed
|
||||
$("#media-modal").on("hidden.bs.modal", function(_e) {
|
||||
m.$destroy();
|
||||
$("#media-modal").remove();
|
||||
});
|
||||
|
||||
// Pop the Component modal
|
||||
$("#media-modal").modal();
|
||||
}
|
||||
|
||||
export function bindMarkdownEditors() {
|
||||
$("textarea.markdown").each(function(_i, e) {
|
||||
if (e.hasOwnProperty("mde") === false) {
|
||||
let mde = new EasyMDE({
|
||||
autoDownloadFontAwesome: false,
|
||||
toolbar: [
|
||||
"bold",
|
||||
"italic",
|
||||
"heading",
|
||||
"|",
|
||||
"quote",
|
||||
"unordered-list",
|
||||
"ordered-list",
|
||||
"|",
|
||||
"link",
|
||||
"image",
|
||||
{
|
||||
name: "media",
|
||||
action: editor => {
|
||||
showMediaLibrary(editor);
|
||||
},
|
||||
className: "fas fa-file-upload",
|
||||
title: "Media Library"
|
||||
},
|
||||
"|",
|
||||
"preview",
|
||||
"guide"
|
||||
],
|
||||
element: this,
|
||||
initialValue: $(this).val(),
|
||||
forceSync: true,
|
||||
minHeight: "200px"
|
||||
});
|
||||
this.mde = mde;
|
||||
this.codemirror = mde.codemirror;
|
||||
$(this).on("change keyup paste", function() {
|
||||
mde.codemirror.getDoc().setValue($(this).val());
|
||||
mde.codemirror.refresh();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default () => {
|
||||
// TODO: This is kind of a hack to mimic a React-like state construct.
|
||||
@@ -9,32 +80,7 @@ export default () => {
|
||||
$(this).data("initial", $(this).val());
|
||||
});
|
||||
|
||||
$(".form-control").bind({
|
||||
focus: function() {
|
||||
$(this).addClass("input-filled-valid");
|
||||
},
|
||||
blur: function() {
|
||||
if ($(this).val() === "") {
|
||||
$(this).removeClass("input-filled-valid");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$(".modal").on("show.bs.modal", function(e) {
|
||||
$(".form-control").each(function() {
|
||||
if ($(this).val()) {
|
||||
$(this).addClass("input-filled-valid");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$(function() {
|
||||
$(".form-control").each(function() {
|
||||
if ($(this).val()) {
|
||||
$(this).addClass("input-filled-valid");
|
||||
}
|
||||
});
|
||||
|
||||
$("tr[data-href], td[data-href]").click(function() {
|
||||
var sel = getSelection().toString();
|
||||
if (!sel) {
|
||||
@@ -96,6 +142,7 @@ export default () => {
|
||||
}
|
||||
}
|
||||
|
||||
bindMarkdownEditors();
|
||||
makeSortableTables();
|
||||
$('[data-toggle="tooltip"]').tooltip();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
html{position:relative;min-height:100%}body{margin-bottom:60px}.footer{position:absolute;bottom:1px;width:100%;height:60px;line-height:normal !important;z-index:-20}
|
||||
|
||||
#score-graph{height:450px;display:block;clear:both}#solves-graph{display:block;height:350px}#keys-pie-graph{height:400px;display:block}#categories-pie-graph{height:400px;display:block}#solve-percentages-graph{height:400px;display:block}.no-decoration{color:inherit !important;text-decoration:none !important}.no-decoration:hover{color:inherit !important;text-decoration:none !important}.table td,.table th{vertical-align:inherit}pre{white-space:pre-wrap;margin:0;padding:0}.form-control{position:relative;display:block;border-radius:0;font-weight:400;font-family:"Avenir Next", "Helvetica Neue", Helvetica, Arial, sans-serif;-webkit-appearance:none}tbody tr:hover{background-color:rgba(0,0,0,0.1) !important}[data-href]{cursor:pointer}.sort-col{cursor:pointer}input[type="checkbox"]{cursor:pointer}
|
||||
#score-graph{min-height:400px;display:block;clear:both}#solves-graph{display:block;height:350px}#keys-pie-graph{min-height:400px;display:block}#categories-pie-graph{min-height:400px;display:block}#solve-percentages-graph{min-height:400px;display:block}#score-distribution-graph{min-height:400px;display:block}.no-decoration{color:inherit !important;text-decoration:none !important}.no-decoration:hover{color:inherit !important;text-decoration:none !important}.table td,.table th{vertical-align:inherit}pre{white-space:pre-wrap;margin:0;padding:0}.form-control{position:relative;display:block;border-radius:0;font-weight:400;font-family:"Avenir Next", "Helvetica Neue", Helvetica, Arial, sans-serif;-webkit-appearance:none}tbody tr:hover{background-color:rgba(0,0,0,0.1) !important}[data-href]{cursor:pointer}.sort-col{cursor:pointer}input[type="checkbox"]{cursor:pointer}
|
||||
|
||||
|
||||
2
CTFd/themes/admin/static/css/admin.min.css
vendored
2
CTFd/themes/admin/static/css/admin.min.css
vendored
@@ -1 +1 @@
|
||||
html{position:relative;min-height:100%}body{margin-bottom:60px}.footer{position:absolute;bottom:1px;width:100%;height:60px;line-height:normal!important;z-index:-20}#score-graph{height:450px;display:block;clear:both}#solves-graph{display:block;height:350px}#categories-pie-graph,#keys-pie-graph,#solve-percentages-graph{height:400px;display:block}.no-decoration,.no-decoration:hover{color:inherit!important;text-decoration:none!important}.table td,.table th{vertical-align:inherit}pre{white-space:pre-wrap;margin:0;padding:0}.form-control{position:relative;display:block;border-radius:0;font-weight:400;font-family:Avenir Next,Helvetica Neue,Helvetica,Arial,sans-serif;-webkit-appearance:none}tbody tr:hover{background-color:rgba(0,0,0,.1)!important}.sort-col,[data-href],input[type=checkbox]{cursor:pointer}
|
||||
html{position:relative;min-height:100%}body{margin-bottom:60px}.footer{position:absolute;bottom:1px;width:100%;height:60px;line-height:normal!important;z-index:-20}#score-graph{min-height:400px;display:block;clear:both}#solves-graph{display:block;height:350px}#categories-pie-graph,#keys-pie-graph,#score-distribution-graph,#solve-percentages-graph{min-height:400px;display:block}.no-decoration,.no-decoration:hover{color:inherit!important;text-decoration:none!important}.table td,.table th{vertical-align:inherit}pre{white-space:pre-wrap;margin:0;padding:0}.form-control{position:relative;display:block;border-radius:0;font-weight:400;font-family:Avenir Next,Helvetica Neue,Helvetica,Arial,sans-serif;-webkit-appearance:none}tbody tr:hover{background-color:rgba(0,0,0,.1)!important}.sort-col,[data-href],input[type=checkbox]{cursor:pointer}
|
||||
@@ -1,2 +1,2 @@
|
||||
.chal-desc{padding-left:30px;padding-right:30px;font-size:14px}.chal-desc img{max-width:100%;height:auto}.modal-content{border-radius:0px;max-width:1000px;padding:1em;margin:0 auto}.btn-info{background-color:#5b7290 !important}.badge-info{background-color:#5b7290 !important}.challenge-button{box-shadow:3px 3px 3px grey}.solved-challenge{background-color:#37d63e !important;opacity:0.4;border:none}.corner-button-check{margin-top:-10px;margin-right:25px;position:absolute;right:0}.key-submit .btn{height:51px}#challenge-window .form-control{position:relative;display:block;padding:0.8em;border-radius:0;background:#f0f0f0;color:#aaa;font-weight:400;font-family:"Avenir Next", "Helvetica Neue", Helvetica, Arial, sans-serif;-webkit-appearance:none;height:auto !important}#challenge-window .form-control:focus{background-color:transparent;border-color:#a3d39c;box-shadow:0 0 0 0.2rem #a3d39c;transition:background-color 0.3s, border-color 0.3s}
|
||||
.chal-desc{padding-left:30px;padding-right:30px;font-size:14px}.chal-desc img{max-width:100%;height:auto}.modal-content{border-radius:0px;max-width:1000px;padding:1em;margin:0 auto}.btn-info{background-color:#5b7290 !important}.badge-info{background-color:#5b7290 !important}.challenge-button{box-shadow:3px 3px 3px grey}.solved-challenge{background-color:#37d63e !important;opacity:0.4;border:none}.corner-button-check{margin-top:-10px;margin-right:25px;position:absolute;right:0}.key-submit .btn{height:51px}#challenge-window .form-control{position:relative;display:block;padding:0.8em;border-radius:0;background:#f0f0f0;color:#aaa;font-weight:400;font-family:"Avenir Next", "Helvetica Neue", Helvetica, Arial, sans-serif;-webkit-appearance:none;height:auto !important}#challenge-window .form-control:focus{background-color:transparent;border-color:#a3d39c;box-shadow:0 0 0 0.1rem #a3d39c;transition:background-color 0.3s, border-color 0.3s}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
.chal-desc{padding-left:30px;padding-right:30px;font-size:14px}.chal-desc img{max-width:100%;height:auto}.modal-content{border-radius:0;max-width:1000px;padding:1em;margin:0 auto}.badge-info,.btn-info{background-color:#5b7290!important}.challenge-button{box-shadow:3px 3px 3px grey}.solved-challenge{background-color:#37d63e!important;opacity:.4;border:none}.corner-button-check{margin-top:-10px;margin-right:25px;position:absolute;right:0}.key-submit .btn{height:51px}#challenge-window .form-control{position:relative;display:block;padding:.8em;border-radius:0;background:#f0f0f0;color:#aaa;font-weight:400;font-family:Avenir Next,Helvetica Neue,Helvetica,Arial,sans-serif;-webkit-appearance:none;height:auto!important}#challenge-window .form-control:focus{background-color:transparent;border-color:#a3d39c;box-shadow:0 0 0 .2rem #a3d39c;transition:background-color .3s,border-color .3s}
|
||||
.chal-desc{padding-left:30px;padding-right:30px;font-size:14px}.chal-desc img{max-width:100%;height:auto}.modal-content{border-radius:0;max-width:1000px;padding:1em;margin:0 auto}.badge-info,.btn-info{background-color:#5b7290!important}.challenge-button{box-shadow:3px 3px 3px grey}.solved-challenge{background-color:#37d63e!important;opacity:.4;border:none}.corner-button-check{margin-top:-10px;margin-right:25px;position:absolute;right:0}.key-submit .btn{height:51px}#challenge-window .form-control{position:relative;display:block;padding:.8em;border-radius:0;background:#f0f0f0;color:#aaa;font-weight:400;font-family:Avenir Next,Helvetica Neue,Helvetica,Arial,sans-serif;-webkit-appearance:none;height:auto!important}#challenge-window .form-control:focus{background-color:transparent;border-color:#a3d39c;box-shadow:0 0 0 .1rem #a3d39c;transition:background-color .3s,border-color .3s}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user