Mark 3.1.0 (#1634)

# 3.1.0 / 2020-09-08

**General**

- Loosen team password confirmation in team settings to also accept the team captain's password to make it easier to change the team password
- Adds the ability to add custom user and team fields for registration/profile settings.
- Improve Notifications pubsub events system to use a subscriber per server instead of a subscriber per browser. This should improve the reliability of CTFd at higher load and make it easier to deploy the Notifications system

**Admin Panel**

- Add a comments functionality for admins to discuss challenges, users, teams, pages
- Adds a legal section in Configs where users can add a terms of service and privacy policy
- Add a Custom Fields section in Configs where admins can add/edit custom user/team fields
- Move user graphs into a modal for Admin Panel

**API**

- Add `/api/v1/comments` to manipulate and create comments

**Themes**

- Make scoreboard caching only cache the score table instead of the entire page. This is done by caching the specific template section. Refer to #1586, specifically the changes in `scoreboard.html`.
- Add rel=noopener to external links to prevent tab napping attacks
- Change the registration page to reference links to Terms of Service and Privacy Policy if specified in configuration

**Miscellaneous**

- Make team settings modal larger in the core theme
- Update tests in Github Actions to properly test under MySQL and Postgres
- Make gevent default in serve.py and add a `--disable-gevent` switch in serve.py
- Add `tenacity` library for retrying logic
- Add `pytest-sugar` for slightly prettier pytest output
- Add a `listen()` method to `CTFd.utils.events.EventManager` and `CTFd.utils.events.RedisEventManager`.
  - This method should implement subscription for a CTFd worker to whatever underlying notification system there is. This should be implemented with gevent or a background thread.
  - The `subscribe()` method (which used to implement the functionality of the new `listen()` function) now only handles passing notifications from CTFd to the browser. This should also be implemented with gevent or a background thread.
This commit is contained in:
Kevin Chung
2020-09-08 00:08:35 -04:00
committed by GitHub
parent c1d7910920
commit 9264e96428
145 changed files with 4714 additions and 364 deletions

View File

@@ -11,7 +11,6 @@ jobs:
strategy:
matrix:
python-version: ['3.6']
TESTING_DATABASE_URL: ['sqlite://']
name: Linting
steps:
@@ -30,6 +29,8 @@ jobs:
- name: Lint
run: make lint
env:
TESTING_DATABASE_URL: 'sqlite://'
- name: Lint Dockerfile
uses: brpaz/hadolint-action@master

View File

@@ -9,9 +9,12 @@ jobs:
runs-on: ubuntu-latest
services:
mysql:
image: mysql
image: mysql:5.7
env:
MYSQL_ROOT_PASSWORD: password
ports:
- 3306:3306
- 3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
redis:
image: redis
ports:
@@ -20,7 +23,6 @@ jobs:
strategy:
matrix:
python-version: ['3.6']
TESTING_DATABASE_URL: ['mysql+pymysql://root@localhost/ctfd']
name: Python ${{ matrix.python-version }}
steps:
@@ -43,6 +45,7 @@ jobs:
env:
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
TESTING_DATABASE_URL: mysql+pymysql://root:password@localhost:${{ job.services.mysql.ports[3306] }}/ctfd
- name: Codecov
uses: codecov/codecov-action@v1.0.11

View File

@@ -15,6 +15,7 @@ jobs:
env:
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_DB: ctfd
POSTGRES_PASSWORD: password
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
@@ -29,7 +30,6 @@ jobs:
strategy:
matrix:
python-version: ['3.6']
TESTING_DATABASE_URL: ['postgres://postgres@localhost/ctfd']
name: Python ${{ matrix.python-version }}
steps:
@@ -52,6 +52,7 @@ jobs:
env:
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
TESTING_DATABASE_URL: postgres://postgres:password@localhost:${{ job.services.postgres.ports[5432] }}/ctfd
- name: Codecov
uses: codecov/codecov-action@v1.0.11

View File

@@ -11,7 +11,6 @@ jobs:
strategy:
matrix:
python-version: ['3.6']
TESTING_DATABASE_URL: ['sqlite://']
name: Python ${{ matrix.python-version }}
steps:
@@ -35,6 +34,7 @@ jobs:
env:
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
TESTING_DATABASE_URL: 'sqlite://'
- name: Codecov
uses: codecov/codecov-action@v1.0.11

View File

@@ -1,3 +1,39 @@
# 3.1.0 / 2020-09-08
**General**
- Loosen team password confirmation in team settings to also accept the team captain's password to make it easier to change the team password
- Adds the ability to add custom user and team fields for registration/profile settings.
- Improve Notifications pubsub events system to use a subscriber per server instead of a subscriber per browser. This should improve the reliability of CTFd at higher load and make it easier to deploy the Notifications system
**Admin Panel**
- Add a comments functionality for admins to discuss challenges, users, teams, pages
- Adds a legal section in Configs where users can add a terms of service and privacy policy
- Add a Custom Fields section in Configs where admins can add/edit custom user/team fields
- Move user graphs into a modal for Admin Panel
**API**
- Add `/api/v1/comments` to manipulate and create comments
**Themes**
- Make scoreboard caching only cache the score table instead of the entire page. This is done by caching the specific template section. Refer to #1586, specifically the changes in `scoreboard.html`.
- Add rel=noopener to external links to prevent tab napping attacks
- Change the registration page to reference links to Terms of Service and Privacy Policy if specified in configuration
**Miscellaneous**
- Make team settings modal larger in the core theme
- Update tests in Github Actions to properly test under MySQL and Postgres
- Make gevent default in serve.py and add a `--disable-gevent` switch in serve.py
- Add `tenacity` library for retrying logic
- Add `pytest-sugar` for slightly prettier pytest output
- Add a `listen()` method to `CTFd.utils.events.EventManager` and `CTFd.utils.events.RedisEventManager`.
- This method should implement subscription for a CTFd worker to whatever underlying notification system there is. This should be implemented with gevent or a background thread.
- The `subscribe()` method (which used to implement the functionality of the new `listen()` function) now only handles passing notifications from CTFd to the browser. This should also be implemented with gevent or a background thread.
# 3.0.2 / 2020-08-23
**Admin Panel**

View File

@@ -26,7 +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
__version__ = "3.0.2"
__version__ = "3.1.0"
__channel__ = "oss"

View File

@@ -3,6 +3,7 @@ from flask_restx import Api
from CTFd.api.v1.awards import awards_namespace
from CTFd.api.v1.challenges import challenges_namespace
from CTFd.api.v1.comments import comments_namespace
from CTFd.api.v1.config import configs_namespace
from CTFd.api.v1.files import files_namespace
from CTFd.api.v1.flags import flags_namespace
@@ -48,3 +49,4 @@ CTFd_API_v1.add_namespace(configs_namespace, "/configs")
CTFd_API_v1.add_namespace(pages_namespace, "/pages")
CTFd_API_v1.add_namespace(unlocks_namespace, "/unlocks")
CTFd_API_v1.add_namespace(tokens_namespace, "/tokens")
CTFd_API_v1.add_namespace(comments_namespace, "/comments")

159
CTFd/api/v1/comments.py Normal file
View File

@@ -0,0 +1,159 @@
from typing import List
from flask import request, session
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.request import validate_args
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.constants import RawEnum
from CTFd.models import (
ChallengeComments,
Comments,
PageComments,
TeamComments,
UserComments,
db,
)
from CTFd.schemas.comments import CommentSchema
from CTFd.utils.decorators import admins_only
from CTFd.utils.helpers.models import build_model_filters
comments_namespace = Namespace("comments", description="Endpoint to retrieve Comments")
CommentModel = sqlalchemy_to_pydantic(Comments)
class CommentDetailedSuccessResponse(APIDetailedSuccessResponse):
data: CommentModel
class CommentListSuccessResponse(APIListSuccessResponse):
data: List[CommentModel]
comments_namespace.schema_model(
"CommentDetailedSuccessResponse", CommentDetailedSuccessResponse.apidoc()
)
comments_namespace.schema_model(
"CommentListSuccessResponse", CommentListSuccessResponse.apidoc()
)
def get_comment_model(data):
model = Comments
if "challenge_id" in data:
model = ChallengeComments
elif "user_id" in data:
model = UserComments
elif "team_id" in data:
model = TeamComments
elif "page_id" in data:
model = PageComments
else:
model = Comments
return model
@comments_namespace.route("")
class CommentList(Resource):
@admins_only
@comments_namespace.doc(
description="Endpoint to list Comment objects in bulk",
responses={
200: ("Success", "CommentListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
@validate_args(
{
"challenge_id": (int, None),
"user_id": (int, None),
"team_id": (int, None),
"page_id": (int, None),
"q": (str, None),
"field": (RawEnum("CommentFields", {"content": "content"}), None),
},
location="query",
)
def get(self, query_args):
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
CommentModel = get_comment_model(data=query_args)
filters = build_model_filters(model=CommentModel, query=q, field=field)
comments = (
CommentModel.query.filter_by(**query_args)
.filter(*filters)
.order_by(CommentModel.id.desc())
.paginate(max_per_page=100)
)
schema = CommentSchema(many=True)
response = schema.dump(comments.items)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {
"meta": {
"pagination": {
"page": comments.page,
"next": comments.next_num,
"prev": comments.prev_num,
"pages": comments.pages,
"per_page": comments.per_page,
"total": comments.total,
}
},
"success": True,
"data": response.data,
}
@admins_only
@comments_namespace.doc(
description="Endpoint to create a Comment object",
responses={
200: ("Success", "CommentDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self):
req = request.get_json()
# Always force author IDs to be the actual user
req["author_id"] = session["id"]
CommentModel = get_comment_model(data=req)
m = CommentModel(**req)
db.session.add(m)
db.session.commit()
schema = CommentSchema()
response = schema.dump(m)
db.session.close()
return {"success": True, "data": response.data}
@comments_namespace.route("/<comment_id>")
class Comment(Resource):
@admins_only
@comments_namespace.doc(
description="Endpoint to delete a specific Comment object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, comment_id):
comment = Comments.query.filter_by(id=comment_id).first_or_404()
db.session.delete(comment)
db.session.commit()
db.session.close()
return {"success": True}

View File

@@ -8,8 +8,9 @@ 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.models import Configs, Fields, db
from CTFd.schemas.config import ConfigSchema
from CTFd.schemas.fields import FieldSchema
from CTFd.utils import set_config
from CTFd.utils.decorators import admins_only
from CTFd.utils.helpers.models import build_model_filters
@@ -189,3 +190,89 @@ class Config(Resource):
clear_standings()
return {"success": True}
@configs_namespace.route("/fields")
class FieldList(Resource):
@admins_only
@validate_args(
{
"type": (str, None),
"q": (str, None),
"field": (RawEnum("FieldFields", {"description": "description"}), None),
},
location="query",
)
def get(self, query_args):
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
filters = build_model_filters(model=Fields, query=q, field=field)
fields = Fields.query.filter_by(**query_args).filter(*filters).all()
schema = FieldSchema(many=True)
response = schema.dump(fields)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
@admins_only
def post(self):
req = request.get_json()
schema = FieldSchema()
response = schema.load(req, session=db.session)
if response.errors:
return {"success": False, "errors": response.errors}, 400
db.session.add(response.data)
db.session.commit()
response = schema.dump(response.data)
db.session.close()
return {"success": True, "data": response.data}
@configs_namespace.route("/fields/<field_id>")
class Field(Resource):
@admins_only
def get(self, field_id):
field = Fields.query.filter_by(id=field_id).first_or_404()
schema = FieldSchema()
response = schema.dump(field)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
@admins_only
def patch(self, field_id):
field = Fields.query.filter_by(id=field_id).first_or_404()
schema = FieldSchema()
req = request.get_json()
response = schema.load(req, session=db.session, instance=field)
if response.errors:
return {"success": False, "errors": response.errors}, 400
db.session.commit()
response = schema.dump(response.data)
db.session.close()
return {"success": True, "data": response.data}
@admins_only
def delete(self, field_id):
field = Fields.query.filter_by(id=field_id).first_or_404()
db.session.delete(field)
db.session.commit()
db.session.close()
return {"success": True}

View File

@@ -7,7 +7,7 @@ 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.models import Teams, UserFieldEntries, UserFields, Users, db
from CTFd.utils import config, email, get_app_config, get_config
from CTFd.utils import user as current_user
from CTFd.utils import validators
@@ -206,6 +206,31 @@ def register():
valid_email = validators.validate_email(email_address)
team_name_email_check = validators.validate_email(name)
# Process additional user fields
fields = {}
for field in UserFields.query.all():
fields[field.id] = field
entries = {}
for field_id, field in fields.items():
value = request.form.get(f"fields[{field_id}]", "").strip()
if field.required is True and (value is None or value == ""):
errors.append("Please provide all required fields")
break
# Handle special casing of existing profile fields
if field.name.lower() == "affiliation":
affiliation = value
break
elif field.name.lower() == "website":
website = value
break
if field.field_type == "boolean":
entries[field_id] = bool(value)
else:
entries[field_id] = value
if country:
try:
validators.validate_country_code(country)
@@ -275,6 +300,13 @@ def register():
db.session.commit()
db.session.flush()
for field_id, value in entries.items():
entry = UserFieldEntries(
field_id=field_id, value=value, user_id=user.id
)
db.session.add(entry)
db.session.commit()
login_user(user)
if config.can_send_mail() and get_config(

View File

@@ -1,5 +1,5 @@
from flask import request
from flask_caching import Cache
from flask_caching import Cache, make_template_fragment_key
cache = Cache()
@@ -27,6 +27,7 @@ def clear_config():
def clear_standings():
from CTFd.models import Users, Teams
from CTFd.constants.static import CacheKeys
from CTFd.utils.scores import get_standings, get_team_standings, get_user_standings
from CTFd.api.v1.scoreboard import ScoreboardDetail, ScoreboardList
from CTFd.api import api
@@ -55,11 +56,13 @@ def clear_standings():
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))
cache.delete_memoized(ScoreboardList.get)
# Clear out scoreboard templates
cache.delete(make_template_fragment_key(CacheKeys.PUBLIC_SCOREBOARD_TABLE))
def clear_pages():
from CTFd.utils.config.pages import get_page, get_pages

View File

@@ -3,6 +3,7 @@ from enum import Enum
from flask import current_app
JS_ENUMS = {}
JINJA_ENUMS = {}
class RawEnum(Enum):
@@ -59,6 +60,7 @@ def JinjaEnum(cls):
"""
if cls.__name__ not in current_app.jinja_env.globals:
current_app.jinja_env.globals[cls.__name__] = cls
JINJA_ENUMS[cls.__name__] = cls
else:
raise KeyError("{} was already defined as a JinjaEnum".format(cls.__name__))
return cls

View File

@@ -1,5 +1,7 @@
import json
from flask import url_for
from CTFd.constants import JinjaEnum, RawEnum
from CTFd.utils import get_config
@@ -63,5 +65,19 @@ class _ConfigsWrapper:
def theme_settings(self):
return json.loads(get_config("theme_settings", default="null"))
@property
def tos_or_privacy(self):
tos = bool(get_config("tos_url") or get_config("tos_text"))
privacy = bool(get_config("privacy_url") or get_config("privacy_text"))
return tos or privacy
@property
def tos_link(self):
return get_config("tos_url", default=url_for("views.tos"))
@property
def privacy_link(self):
return get_config("privacy_url", default=url_for("views.privacy"))
Configs = _ConfigsWrapper()

14
CTFd/constants/static.py Normal file
View File

@@ -0,0 +1,14 @@
from CTFd.constants import JinjaEnum, RawEnum
@JinjaEnum
class CacheKeys(str, RawEnum):
PUBLIC_SCOREBOARD_TABLE = "public_scoreboard_table"
# Placeholder object. Not used, just imported to force initialization of any Enums here
class _StaticsWrapper:
pass
Static = _StaticsWrapper()

View File

@@ -4,13 +4,25 @@ from wtforms.validators import InputRequired
from CTFd.forms import BaseForm
from CTFd.forms.fields import SubmitField
from CTFd.forms.users import attach_custom_user_fields, build_custom_user_fields
class RegistrationForm(BaseForm):
name = StringField("User Name", validators=[InputRequired()])
email = EmailField("Email", validators=[InputRequired()])
password = PasswordField("Password", validators=[InputRequired()])
submit = SubmitField("Submit")
def RegistrationForm(*args, **kwargs):
class _RegistrationForm(BaseForm):
name = StringField("User Name", validators=[InputRequired()])
email = EmailField("Email", validators=[InputRequired()])
password = PasswordField("Password", validators=[InputRequired()])
submit = SubmitField("Submit")
@property
def extra(self):
return build_custom_user_fields(
self, include_entries=False, blacklisted_items=()
)
attach_custom_user_fields(_RegistrationForm)
return _RegistrationForm(*args, **kwargs)
class LoginForm(BaseForm):

View File

@@ -1,5 +1,5 @@
from wtforms import BooleanField, SelectField, StringField
from wtforms.fields.html5 import IntegerField
from wtforms import BooleanField, SelectField, StringField, TextAreaField
from wtforms.fields.html5 import IntegerField, URLField
from wtforms.widgets.html5 import NumberInput
from CTFd.forms import BaseForm
@@ -60,3 +60,21 @@ class ExportCSVForm(BaseForm):
),
)
submit = SubmitField("Download CSV")
class LegalSettingsForm(BaseForm):
tos_url = URLField(
"Terms of Service URL",
description="External URL to a Terms of Service document hosted elsewhere",
)
tos_text = TextAreaField(
"Terms of Service", description="Text shown on the Terms of Service page",
)
privacy_url = URLField(
"Privacy Policy URL",
description="External URL to a Privacy Policy document hosted elsewhere",
)
privacy_text = TextAreaField(
"Privacy Policy", description="Text shown on the Privacy Policy page",
)
submit = SubmitField("Update")

View File

@@ -1,20 +1,36 @@
from flask import session
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.forms.users import attach_custom_user_fields, build_custom_user_fields
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")
def SettingsForm(*args, **kwargs):
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")
@property
def extra(self):
return build_custom_user_fields(
self,
include_entries=True,
fields_kwargs={"editable": True},
field_entries_kwargs={"user_id": session["id"]},
)
attach_custom_user_fields(_SettingsForm, editable=True)
return _SettingsForm(*args, **kwargs)
class TokensForm(BaseForm):

View File

@@ -4,29 +4,142 @@ from wtforms.validators import InputRequired
from CTFd.forms import BaseForm
from CTFd.forms.fields import SubmitField
from CTFd.models import TeamFieldEntries, TeamFields
from CTFd.utils.countries import SELECT_COUNTRIES_LIST
def build_custom_team_fields(
form_cls,
include_entries=False,
fields_kwargs=None,
field_entries_kwargs=None,
blacklisted_items=("affiliation", "website"),
):
if fields_kwargs is None:
fields_kwargs = {}
if field_entries_kwargs is None:
field_entries_kwargs = {}
fields = []
new_fields = TeamFields.query.filter_by(**fields_kwargs).all()
user_fields = {}
# Only include preexisting values if asked
if include_entries is True:
for f in TeamFieldEntries.query.filter_by(**field_entries_kwargs).all():
user_fields[f.field_id] = f.value
for field in new_fields:
if field.name.lower() in blacklisted_items:
continue
form_field = getattr(form_cls, f"fields[{field.id}]")
# Add the field_type to the field so we know how to render it
form_field.field_type = field.field_type
# Only include preexisting values if asked
if include_entries is True:
initial = user_fields.get(field.id, "")
form_field.data = initial
if form_field.render_kw:
form_field.render_kw["data-initial"] = initial
else:
form_field.render_kw = {"data-initial": initial}
fields.append(form_field)
return fields
def attach_custom_team_fields(form_cls, **kwargs):
new_fields = TeamFields.query.filter_by(**kwargs).all()
for field in new_fields:
validators = []
if field.required:
validators.append(InputRequired())
if field.field_type == "text":
input_field = StringField(
field.name, description=field.description, validators=validators
)
elif field.field_type == "boolean":
input_field = BooleanField(
field.name, description=field.description, validators=validators
)
setattr(form_cls, f"fields[{field.id}]", input_field)
class TeamJoinForm(BaseForm):
name = StringField("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")
def TeamRegisterForm(*args, **kwargs):
class _TeamRegisterForm(BaseForm):
name = StringField("Team Name", validators=[InputRequired()])
password = PasswordField("Team Password", validators=[InputRequired()])
submit = SubmitField("Create")
@property
def extra(self):
return build_custom_team_fields(
self, include_entries=False, blacklisted_items=()
)
attach_custom_team_fields(_TeamRegisterForm)
return _TeamRegisterForm(*args, **kwargs)
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")
def TeamSettingsForm(*args, **kwargs):
class _TeamSettingsForm(BaseForm):
name = StringField(
"Team Name",
description="Your team's public name shown to other competitors",
)
password = PasswordField(
"New Team Password", description="Set a new team join password"
)
confirm = PasswordField(
"Confirm Password",
description="Provide your current team password (or your password) to update your team's password",
)
affiliation = StringField(
"Affiliation",
description="Your team's affiliation publicly shown to other competitors",
)
website = URLField(
"Website",
description="Your team's website publicly shown to other competitors",
)
country = SelectField(
"Country",
choices=SELECT_COUNTRIES_LIST,
description="Your team's country publicly shown to other competitors",
)
submit = SubmitField("Submit")
@property
def extra(self):
return build_custom_team_fields(
self,
include_entries=True,
fields_kwargs={"editable": True},
field_entries_kwargs={"team_id": self.obj.id},
)
def __init__(self, *args, **kwargs):
"""
Custom init to persist the obj parameter to the rest of the form
"""
super().__init__(*args, **kwargs)
obj = kwargs.get("obj")
if obj:
self.obj = obj
attach_custom_team_fields(_TeamSettingsForm)
return _TeamSettingsForm(*args, **kwargs)
class TeamCaptainForm(BaseForm):
@@ -66,7 +179,7 @@ class PublicTeamSearchForm(BaseForm):
submit = SubmitField("Search")
class TeamCreateForm(BaseForm):
class TeamBaseForm(BaseForm):
name = StringField("Team Name", validators=[InputRequired()])
email = EmailField("Email")
password = PasswordField("Password")
@@ -78,5 +191,41 @@ class TeamCreateForm(BaseForm):
submit = SubmitField("Submit")
class TeamEditForm(TeamCreateForm):
pass
def TeamCreateForm(*args, **kwargs):
class _TeamCreateForm(TeamBaseForm):
pass
@property
def extra(self):
return build_custom_team_fields(self, include_entries=False)
attach_custom_team_fields(_TeamCreateForm)
return _TeamCreateForm(*args, **kwargs)
def TeamEditForm(*args, **kwargs):
class _TeamEditForm(TeamBaseForm):
pass
@property
def extra(self):
return build_custom_team_fields(
self,
include_entries=True,
fields_kwargs=None,
field_entries_kwargs={"team_id": self.obj.id},
)
def __init__(self, *args, **kwargs):
"""
Custom init to persist the obj parameter to the rest of the form
"""
super().__init__(*args, **kwargs)
obj = kwargs.get("obj")
if obj:
self.obj = obj
attach_custom_team_fields(_TeamEditForm)
return _TeamEditForm(*args, **kwargs)

View File

@@ -4,9 +4,81 @@ from wtforms.validators import InputRequired
from CTFd.forms import BaseForm
from CTFd.forms.fields import SubmitField
from CTFd.models import UserFieldEntries, UserFields
from CTFd.utils.countries import SELECT_COUNTRIES_LIST
def build_custom_user_fields(
form_cls,
include_entries=False,
fields_kwargs=None,
field_entries_kwargs=None,
blacklisted_items=("affiliation", "website"),
):
"""
Function used to reinject values back into forms for accessing by themes
"""
if fields_kwargs is None:
fields_kwargs = {}
if field_entries_kwargs is None:
field_entries_kwargs = {}
fields = []
new_fields = UserFields.query.filter_by(**fields_kwargs).all()
user_fields = {}
# Only include preexisting values if asked
if include_entries is True:
for f in UserFieldEntries.query.filter_by(**field_entries_kwargs).all():
user_fields[f.field_id] = f.value
for field in new_fields:
if field.name.lower() in blacklisted_items:
continue
form_field = getattr(form_cls, f"fields[{field.id}]")
# Add the field_type to the field so we know how to render it
form_field.field_type = field.field_type
# Only include preexisting values if asked
if include_entries is True:
initial = user_fields.get(field.id, "")
form_field.data = initial
if form_field.render_kw:
form_field.render_kw["data-initial"] = initial
else:
form_field.render_kw = {"data-initial": initial}
fields.append(form_field)
return fields
def attach_custom_user_fields(form_cls, **kwargs):
"""
Function used to attach form fields to wtforms.
Not really a great solution but is approved by wtforms.
https://wtforms.readthedocs.io/en/2.3.x/specific_problems/#dynamic-form-composition
"""
new_fields = UserFields.query.filter_by(**kwargs).all()
for field in new_fields:
validators = []
if field.required:
validators.append(InputRequired())
if field.field_type == "text":
input_field = StringField(
field.name, description=field.description, validators=validators
)
elif field.field_type == "boolean":
input_field = BooleanField(
field.name, description=field.description, validators=validators
)
setattr(form_cls, f"fields[{field.id}]", input_field)
class UserSearchForm(BaseForm):
field = SelectField(
"Search Field",
@@ -40,7 +112,7 @@ class PublicUserSearchForm(BaseForm):
submit = SubmitField("Search")
class UserEditForm(BaseForm):
class UserBaseForm(BaseForm):
name = StringField("User Name", validators=[InputRequired()])
email = EmailField("Email", validators=[InputRequired()])
password = PasswordField("Password")
@@ -54,5 +126,41 @@ class UserEditForm(BaseForm):
submit = SubmitField("Submit")
class UserCreateForm(UserEditForm):
notify = BooleanField("Email account credentials to user", default=True)
def UserEditForm(*args, **kwargs):
class _UserEditForm(UserBaseForm):
pass
@property
def extra(self):
return build_custom_user_fields(
self,
include_entries=True,
fields_kwargs=None,
field_entries_kwargs={"user_id": self.obj.id},
)
def __init__(self, *args, **kwargs):
"""
Custom init to persist the obj parameter to the rest of the form
"""
super().__init__(*args, **kwargs)
obj = kwargs.get("obj")
if obj:
self.obj = obj
attach_custom_user_fields(_UserEditForm)
return _UserEditForm(*args, **kwargs)
def UserCreateForm(*args, **kwargs):
class _UserCreateForm(UserBaseForm):
notify = BooleanField("Email account credentials to user", default=True)
@property
def extra(self):
return build_custom_user_fields(self, include_entries=False)
attach_custom_user_fields(_UserCreateForm)
return _UserCreateForm(*args, **kwargs)

View File

@@ -77,6 +77,7 @@ class Challenges(db.Model):
tags = db.relationship("Tags", backref="challenge")
hints = db.relationship("Hints", backref="challenge")
flags = db.relationship("Flags", backref="challenge")
comments = db.relationship("ChallengeComments", backref="challenge")
class alt_defaultdict(defaultdict):
"""
@@ -275,6 +276,10 @@ class Users(db.Model):
# Relationship for Teams
team_id = db.Column(db.Integer, db.ForeignKey("teams.id"))
field_entries = db.relationship(
"UserFieldEntries", foreign_keys="UserFieldEntries.user_id", lazy="joined"
)
created = db.Column(db.DateTime, default=datetime.datetime.utcnow)
__mapper_args__ = {"polymorphic_identity": "user", "polymorphic_on": type}
@@ -308,6 +313,10 @@ class Users(db.Model):
elif user_mode == "users":
return self
@property
def fields(self):
return self.get_fields(admin=False)
@property
def solves(self):
return self.get_solves(admin=False)
@@ -333,6 +342,14 @@ class Users(db.Model):
else:
return None
def get_fields(self, admin=False):
if admin:
return self.field_entries
return [
entry for entry in self.field_entries if entry.field.public and entry.value
]
def get_solves(self, admin=False):
from CTFd.utils import get_config
@@ -452,6 +469,10 @@ class Teams(db.Model):
captain_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"))
captain = db.relationship("Users", foreign_keys=[captain_id])
field_entries = db.relationship(
"TeamFieldEntries", foreign_keys="TeamFieldEntries.team_id", lazy="joined"
)
created = db.Column(db.DateTime, default=datetime.datetime.utcnow)
def __init__(self, **kwargs):
@@ -463,6 +484,10 @@ class Teams(db.Model):
return hash_password(str(plaintext))
@property
def fields(self):
return self.get_fields(admin=False)
@property
def solves(self):
return self.get_solves(admin=False)
@@ -488,6 +513,14 @@ class Teams(db.Model):
else:
return None
def get_fields(self, admin=False):
if admin:
return self.field_entries
return [
entry for entry in self.field_entries if entry.field.public and entry.value
]
def get_solves(self, admin=False):
from CTFd.utils import get_config
@@ -739,3 +772,100 @@ class Tokens(db.Model):
class UserTokens(Tokens):
__mapper_args__ = {"polymorphic_identity": "user"}
class Comments(db.Model):
__tablename__ = "comments"
id = db.Column(db.Integer, primary_key=True)
type = db.Column(db.String(80), default="standard")
content = db.Column(db.Text)
date = db.Column(db.DateTime, default=datetime.datetime.utcnow)
author_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"))
author = db.relationship("Users", foreign_keys="Comments.author_id", lazy="select")
@property
def html(self):
from CTFd.utils.config.pages import build_html
from CTFd.utils.helpers import markup
return markup(build_html(self.content, sanitize=True))
__mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type}
class ChallengeComments(Comments):
__mapper_args__ = {"polymorphic_identity": "challenge"}
challenge_id = db.Column(
db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE")
)
class UserComments(Comments):
__mapper_args__ = {"polymorphic_identity": "user"}
user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"))
class TeamComments(Comments):
__mapper_args__ = {"polymorphic_identity": "team"}
team_id = db.Column(db.Integer, db.ForeignKey("teams.id", ondelete="CASCADE"))
class PageComments(Comments):
__mapper_args__ = {"polymorphic_identity": "page"}
page_id = db.Column(db.Integer, db.ForeignKey("pages.id", ondelete="CASCADE"))
class Fields(db.Model):
__tablename__ = "fields"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.Text)
type = db.Column(db.String(80), default="standard")
field_type = db.Column(db.String(80))
description = db.Column(db.Text)
required = db.Column(db.Boolean, default=False)
public = db.Column(db.Boolean, default=False)
editable = db.Column(db.Boolean, default=False)
__mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type}
class UserFields(Fields):
__mapper_args__ = {"polymorphic_identity": "user"}
class TeamFields(Fields):
__mapper_args__ = {"polymorphic_identity": "team"}
class FieldEntries(db.Model):
__tablename__ = "field_entries"
id = db.Column(db.Integer, primary_key=True)
type = db.Column(db.String(80), default="standard")
value = db.Column(db.JSON)
field_id = db.Column(db.Integer, db.ForeignKey("fields.id", ondelete="CASCADE"))
field = db.relationship(
"Fields", foreign_keys="FieldEntries.field_id", lazy="joined"
)
__mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type}
@hybrid_property
def name(self):
return self.field.name
@hybrid_property
def description(self):
return self.field.description
class UserFieldEntries(FieldEntries):
__mapper_args__ = {"polymorphic_identity": "user"}
user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"))
user = db.relationship("Users", foreign_keys="UserFieldEntries.user_id")
class TeamFieldEntries(FieldEntries):
__mapper_args__ = {"polymorphic_identity": "team"}
team_id = db.Column(db.Integer, db.ForeignKey("teams.id", ondelete="CASCADE"))
team = db.relationship("Teams", foreign_keys="TeamFieldEntries.team_id")

14
CTFd/schemas/comments.py Normal file
View File

@@ -0,0 +1,14 @@
from marshmallow import fields
from CTFd.models import Comments, ma
from CTFd.schemas.users import UserSchema
class CommentSchema(ma.ModelSchema):
class Meta:
model = Comments
include_fk = True
dump_only = ("id", "date", "html", "author", "author_id", "type")
author = fields.Nested(UserSchema(only=("name",)))
html = fields.String()

38
CTFd/schemas/fields.py Normal file
View File

@@ -0,0 +1,38 @@
from marshmallow import fields
from CTFd.models import Fields, TeamFieldEntries, UserFieldEntries, db, ma
class FieldSchema(ma.ModelSchema):
class Meta:
model = Fields
include_fk = True
dump_only = ("id",)
class UserFieldEntriesSchema(ma.ModelSchema):
class Meta:
model = UserFieldEntries
sqla_session = db.session
include_fk = True
load_only = ("id",)
exclude = ("field", "user", "user_id")
dump_only = ("user_id", "name", "description", "type")
name = fields.Nested(FieldSchema, only=("name"), attribute="field")
description = fields.Nested(FieldSchema, only=("description"), attribute="field")
type = fields.Nested(FieldSchema, only=("field_type"), attribute="field")
class TeamFieldEntriesSchema(ma.ModelSchema):
class Meta:
model = TeamFieldEntries
sqla_session = db.session
include_fk = True
load_only = ("id",)
exclude = ("field", "team", "team_id")
dump_only = ("team_id", "name", "description", "type")
name = fields.Nested(FieldSchema, only=("name"), attribute="field")
description = fields.Nested(FieldSchema, only=("description"), attribute="field")
type = fields.Nested(FieldSchema, only=("field_type"), attribute="field")

View File

@@ -1,7 +1,10 @@
from marshmallow import ValidationError, pre_load, validate
from marshmallow import ValidationError, post_dump, pre_load, validate
from marshmallow.fields import Nested
from marshmallow_sqlalchemy import field_for
from sqlalchemy.orm import load_only
from CTFd.models import Teams, Users, ma
from CTFd.models import TeamFieldEntries, TeamFields, Teams, Users, ma
from CTFd.schemas.fields import TeamFieldEntriesSchema
from CTFd.utils import get_config, string_types
from CTFd.utils.crypto import verify_password
from CTFd.utils.user import get_current_team, get_current_user, is_admin
@@ -44,6 +47,9 @@ class TeamSchema(ma.ModelSchema):
],
)
country = field_for(Teams, "country", validate=[validate_country_code])
fields = Nested(
TeamFieldEntriesSchema, partial=True, many=True, attribute="field_entries"
)
@pre_load
def validate_name(self, data):
@@ -142,10 +148,13 @@ class TeamSchema(ma.ModelSchema):
)
if password and confirm:
test = verify_password(
test_team = verify_password(
plaintext=confirm, ciphertext=current_team.password
)
if test is True:
test_captain = verify_password(
plaintext=confirm, ciphertext=current_user.password
)
if test_team is True or test_captain is True:
return data
else:
raise ValidationError(
@@ -183,6 +192,126 @@ class TeamSchema(ma.ModelSchema):
field_names=["captain_id"],
)
@pre_load
def validate_fields(self, data):
"""
This validator is used to only allow users to update the field entry for their user.
It's not possible to exclude it because without the PK Marshmallow cannot load the right instance
"""
fields = data.get("fields")
if fields is None:
return
current_team = get_current_team()
if is_admin():
team_id = data.get("id")
if team_id:
target_team = Teams.query.filter_by(id=data["id"]).first()
else:
target_team = current_team
# We are editting an existing
if self.view == "admin" and self.instance:
target_team = self.instance
provided_ids = []
for f in fields:
f.pop("id", None)
field_id = f.get("field_id")
# # Check that we have an existing field for this. May be unnecessary b/c the foriegn key should enforce
field = TeamFields.query.filter_by(id=field_id).first_or_404()
# Get the existing field entry if one exists
entry = TeamFieldEntries.query.filter_by(
field_id=field.id, team_id=target_team.id
).first()
if entry:
f["id"] = entry.id
provided_ids.append(entry.id)
# Extremely dirty hack to prevent deleting previously provided data.
# This needs a better soln.
entries = (
TeamFieldEntries.query.options(load_only("id"))
.filter_by(team_id=target_team.id)
.all()
)
for entry in entries:
if entry.id not in provided_ids:
fields.append({"id": entry.id})
else:
provided_ids = []
for f in fields:
# Remove any existing set
f.pop("id", None)
field_id = f.get("field_id")
value = f.get("value")
# # Check that we have an existing field for this. May be unnecessary b/c the foriegn key should enforce
field = TeamFields.query.filter_by(id=field_id).first_or_404()
if field.required is True and value.strip() == "":
raise ValidationError(
f"Field '{field.name}' is required", field_names=["fields"]
)
if field.editable is False:
raise ValidationError(
f"Field '{field.name}' cannot be editted",
field_names=["fields"],
)
# Get the existing field entry if one exists
entry = TeamFieldEntries.query.filter_by(
field_id=field.id, team_id=current_team.id
).first()
if entry:
f["id"] = entry.id
provided_ids.append(entry.id)
# Extremely dirty hack to prevent deleting previously provided data.
# This needs a better soln.
entries = (
TeamFieldEntries.query.options(load_only("id"))
.filter_by(team_id=current_team.id)
.all()
)
for entry in entries:
if entry.id not in provided_ids:
fields.append({"id": entry.id})
@post_dump
def process_fields(self, data):
"""
Handle permissions levels for fields.
This is post_dump to manipulate JSON instead of the raw db object
Admins can see all fields.
Users (self) can see their edittable and public fields
Public (user) can only see public fields
"""
# Gather all possible fields
removed_field_ids = []
fields = TeamFields.query.all()
# Select fields for removal based on current view and properties of the field
for field in fields:
if self.view == "user":
if field.public is False:
removed_field_ids.append(field.id)
elif self.view == "self":
if field.editable is False and field.public is False:
removed_field_ids.append(field.id)
# Rebuild fuilds
fields = data.get("fields")
if fields:
data["fields"] = [
field for field in fields if field["field_id"] not in removed_field_ids
]
views = {
"user": [
"website",
@@ -194,6 +323,7 @@ class TeamSchema(ma.ModelSchema):
"id",
"oauth_id",
"captain_id",
"fields",
],
"self": [
"website",
@@ -207,6 +337,7 @@ class TeamSchema(ma.ModelSchema):
"oauth_id",
"password",
"captain_id",
"fields",
],
"admin": [
"website",
@@ -224,6 +355,7 @@ class TeamSchema(ma.ModelSchema):
"oauth_id",
"password",
"captain_id",
"fields",
],
}
@@ -233,5 +365,6 @@ class TeamSchema(ma.ModelSchema):
kwargs["only"] = self.views[view]
elif isinstance(view, list):
kwargs["only"] = view
self.view = view
super(TeamSchema, self).__init__(*args, **kwargs)

View File

@@ -1,7 +1,10 @@
from marshmallow import ValidationError, pre_load, validate
from marshmallow import ValidationError, post_dump, pre_load, validate
from marshmallow.fields import Nested
from marshmallow_sqlalchemy import field_for
from sqlalchemy.orm import load_only
from CTFd.models import Users, ma
from CTFd.models import UserFieldEntries, UserFields, Users, ma
from CTFd.schemas.fields import UserFieldEntriesSchema
from CTFd.utils import get_config, string_types
from CTFd.utils.crypto import verify_password
from CTFd.utils.email import check_email_is_whitelisted
@@ -49,6 +52,9 @@ class UserSchema(ma.ModelSchema):
)
country = field_for(Users, "country", validate=[validate_country_code])
password = field_for(Users, "password")
fields = Nested(
UserFieldEntriesSchema, partial=True, many=True, attribute="field_entries"
)
@pre_load
def validate_name(self, data):
@@ -180,6 +186,126 @@ class UserSchema(ma.ModelSchema):
data.pop("password", None)
data.pop("confirm", None)
@pre_load
def validate_fields(self, data):
"""
This validator is used to only allow users to update the field entry for their user.
It's not possible to exclude it because without the PK Marshmallow cannot load the right instance
"""
fields = data.get("fields")
if fields is None:
return
current_user = get_current_user()
if is_admin():
user_id = data.get("id")
if user_id:
target_user = Users.query.filter_by(id=data["id"]).first()
else:
target_user = current_user
# We are editting an existing user
if self.view == "admin" and self.instance:
target_user = self.instance
provided_ids = []
for f in fields:
f.pop("id", None)
field_id = f.get("field_id")
# # Check that we have an existing field for this. May be unnecessary b/c the foriegn key should enforce
field = UserFields.query.filter_by(id=field_id).first_or_404()
# Get the existing field entry if one exists
entry = UserFieldEntries.query.filter_by(
field_id=field.id, user_id=target_user.id
).first()
if entry:
f["id"] = entry.id
provided_ids.append(entry.id)
# Extremely dirty hack to prevent deleting previously provided data.
# This needs a better soln.
entries = (
UserFieldEntries.query.options(load_only("id"))
.filter_by(user_id=target_user.id)
.all()
)
for entry in entries:
if entry.id not in provided_ids:
fields.append({"id": entry.id})
else:
provided_ids = []
for f in fields:
# Remove any existing set
f.pop("id", None)
field_id = f.get("field_id")
value = f.get("value")
# # Check that we have an existing field for this. May be unnecessary b/c the foriegn key should enforce
field = UserFields.query.filter_by(id=field_id).first_or_404()
if field.required is True and value.strip() == "":
raise ValidationError(
f"Field '{field.name}' is required", field_names=["fields"]
)
if field.editable is False:
raise ValidationError(
f"Field '{field.name}' cannot be editted",
field_names=["fields"],
)
# Get the existing field entry if one exists
entry = UserFieldEntries.query.filter_by(
field_id=field.id, user_id=current_user.id
).first()
if entry:
f["id"] = entry.id
provided_ids.append(entry.id)
# Extremely dirty hack to prevent deleting previously provided data.
# This needs a better soln.
entries = (
UserFieldEntries.query.options(load_only("id"))
.filter_by(user_id=current_user.id)
.all()
)
for entry in entries:
if entry.id not in provided_ids:
fields.append({"id": entry.id})
@post_dump
def process_fields(self, data):
"""
Handle permissions levels for fields.
This is post_dump to manipulate JSON instead of the raw db object
Admins can see all fields.
Users (self) can see their edittable and public fields
Public (user) can only see public fields
"""
# Gather all possible fields
removed_field_ids = []
fields = UserFields.query.all()
# Select fields for removal based on current view and properties of the field
for field in fields:
if self.view == "user":
if field.public is False:
removed_field_ids.append(field.id)
elif self.view == "self":
if field.editable is False and field.public is False:
removed_field_ids.append(field.id)
# Rebuild fuilds
fields = data.get("fields")
if fields:
data["fields"] = [
field for field in fields if field["field_id"] not in removed_field_ids
]
views = {
"user": [
"website",
@@ -189,6 +315,7 @@ class UserSchema(ma.ModelSchema):
"bracket",
"id",
"oauth_id",
"fields",
],
"self": [
"website",
@@ -200,6 +327,7 @@ class UserSchema(ma.ModelSchema):
"id",
"oauth_id",
"password",
"fields",
],
"admin": [
"website",
@@ -217,6 +345,7 @@ class UserSchema(ma.ModelSchema):
"password",
"type",
"verified",
"fields",
],
}
@@ -226,5 +355,6 @@ class UserSchema(ma.ModelSchema):
kwargs["only"] = self.views[view]
elif isinstance(view, list):
kwargs["only"] = view
self.view = view
super(UserSchema, self).__init__(*args, **kwargs)

View File

@@ -1,6 +1,5 @@
from flask import Blueprint, render_template
from CTFd.cache import cache, make_cache_key
from CTFd.utils import config
from CTFd.utils.config.visibility import scores_visible
from CTFd.utils.decorators.visibility import check_score_visibility
@@ -13,7 +12,6 @@ scoreboard = Blueprint("scoreboard", __name__)
@scoreboard.route("/scoreboard")
@check_score_visibility
@cache.cached(timeout=60, key_prefix=make_cache_key)
def listing():
infos = get_infos()

View File

@@ -1,8 +1,8 @@
from flask import Blueprint, redirect, render_template, request, url_for
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.models import TeamFieldEntries, TeamFields, Teams, db
from CTFd.utils import config, get_config, validators
from CTFd.utils.crypto import verify_password
from CTFd.utils.decorators import authed_only, ratelimit
from CTFd.utils.decorators.modes import require_team_mode
@@ -125,6 +125,9 @@ def new():
passphrase = request.form.get("password", "").strip()
errors = get_errors()
website = request.form.get("website")
affiliation = request.form.get("affiliation")
user = get_current_user()
existing_team = Teams.query.filter_by(name=teamname).first()
@@ -133,14 +136,64 @@ def new():
if not teamname:
errors.append("That team name is invalid")
# Process additional user fields
fields = {}
for field in TeamFields.query.all():
fields[field.id] = field
entries = {}
for field_id, field in fields.items():
value = request.form.get(f"fields[{field_id}]", "").strip()
if field.required is True and (value is None or value == ""):
errors.append("Please provide all required fields")
break
# Handle special casing of existing profile fields
if field.name.lower() == "affiliation":
affiliation = value
break
elif field.name.lower() == "website":
website = value
break
if field.field_type == "boolean":
entries[field_id] = bool(value)
else:
entries[field_id] = value
if website:
valid_website = validators.validate_url(website)
else:
valid_website = True
if affiliation:
valid_affiliation = len(affiliation) < 128
else:
valid_affiliation = True
if valid_website is False:
errors.append("Websites must be a proper URL starting with http or https")
if valid_affiliation is False:
errors.append("Please provide a shorter affiliation")
if errors:
return render_template("teams/new_team.html", errors=errors)
team = Teams(name=teamname, password=passphrase, captain_id=user.id)
if website:
team.website = website
if affiliation:
team.affiliation = affiliation
db.session.add(team)
db.session.commit()
for field_id, value in entries.items():
entry = TeamFieldEntries(field_id=field_id, value=value, team_id=team.id)
db.session.add(entry)
db.session.commit()
user.team_id = team.id
db.session.commit()

View File

@@ -0,0 +1,237 @@
<template>
<div>
<div class="row mb-3">
<div class="col-md-12">
<div class="comment">
<textarea
class="form-control mb-2"
rows="2"
id="comment-input"
placeholder="Add comment"
v-model.lazy="comment"
></textarea>
<button
class="btn btn-sm btn-success btn-outlined float-right"
type="submit"
@click="submitComment()"
>
Comment
</button>
</div>
</div>
</div>
<div class="row" v-if="pages > 1">
<div class="col-md-12">
<div class="text-center">
<!-- Inversed ternary b/c disabled will turn the button off -->
<button
type="button"
class="btn btn-link p-0"
@click="prevPage()"
:disabled="prev ? false : true"
>
&lt;&lt;&lt;
</button>
<button
type="button"
class="btn btn-link p-0"
@click="nextPage()"
:disabled="next ? false : true"
>
&gt;&gt;&gt;
</button>
</div>
</div>
<div class="col-md-12">
<div class="text-center">
<small class="text-muted"
>Page {{ page }} of {{ total }} comments</small
>
</div>
</div>
</div>
<div class="comments">
<transition-group name="comment-card">
<div
class="comment-card card mb-2"
v-for="comment in comments"
:key="comment.id"
>
<div class="card-body pl-0 pb-0 pt-2 pr-2">
<button
type="button"
class="close float-right"
aria-label="Close"
@click="deleteComment(comment.id)"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="card-body">
<div class="card-text" v-html="comment.html"></div>
<small class="text-muted float-left">
<span>
<a :href="`${urlRoot}/admin/users/${comment.author_id}`">{{
comment.author.name
}}</a>
</span>
</small>
<small class="text-muted float-right">
<span class="float-right">{{ toLocalTime(comment.date) }}</span>
</small>
</div>
</div>
</transition-group>
</div>
<div class="row" v-if="pages > 1">
<div class="col-md-12">
<div class="text-center">
<!-- Inversed ternary b/c disabled will turn the button off -->
<button
type="button"
class="btn btn-link p-0"
@click="prevPage()"
:disabled="prev ? false : true"
>
&lt;&lt;&lt;
</button>
<button
type="button"
class="btn btn-link p-0"
@click="nextPage()"
:disabled="next ? false : true"
>
&gt;&gt;&gt;
</button>
</div>
</div>
<div class="col-md-12">
<div class="text-center">
<small class="text-muted"
>Page {{ page }} of {{ total }} comments</small
>
</div>
</div>
</div>
</div>
</template>
<script>
import CTFd from "core/CTFd";
import { default as helpers } from "core/helpers";
import Moment from "moment";
export default {
props: {
// These props are passed to the api via query string.
// See this.getArgs()
type: String,
id: Number
},
data: function() {
return {
page: 1,
pages: null,
next: null,
prev: null,
total: null,
comment: "",
comments: [],
urlRoot: CTFd.config.urlRoot
};
},
methods: {
toLocalTime(date) {
return Moment(date)
.local()
.format("MMMM Do, h:mm:ss A");
},
nextPage: function() {
this.page++;
this.loadComments();
},
prevPage: function() {
this.page--;
this.loadComments();
},
getArgs: function() {
let args = {};
args[`${this.$props.type}_id`] = this.$props.id;
return args;
},
loadComments: function() {
let apiArgs = this.getArgs();
apiArgs[`page`] = this.page;
apiArgs[`per_page`] = 10;
helpers.comments.get_comments(apiArgs).then(response => {
this.page = response.meta.pagination.page;
this.pages = response.meta.pagination.pages;
this.next = response.meta.pagination.next;
this.prev = response.meta.pagination.prev;
this.total = response.meta.pagination.total;
this.comments = response.data;
return this.comments;
});
},
submitComment: function() {
let comment = this.comment.trim();
if (comment.length > 0) {
helpers.comments.add_comment(
comment,
this.$props.type,
this.getArgs(),
() => {
this.loadComments();
}
);
}
this.comment = "";
},
deleteComment: function(commentId) {
if (confirm("Are you sure you'd like to delete this comment?")) {
helpers.comments.delete_comment(commentId).then(response => {
if (response.success === true) {
for (let i = this.comments.length - 1; i >= 0; --i) {
if (this.comments[i].id == commentId) {
this.comments.splice(i, 1);
}
}
}
});
}
}
},
created() {
this.loadComments();
}
};
</script>
<style scoped>
.card .close {
opacity: 0;
transition: 0.2s;
}
.card:hover .close {
opacity: 0.5;
}
.close:hover {
opacity: 0.75 !important;
}
.comment-card-leave {
max-height: 200px;
}
.comment-card-leave-to {
max-height: 0;
}
.comment-card-active {
position: absolute;
}
.comment-card-enter-active,
.comment-card-move,
.comment-card-leave-active {
transition: all 0.3s;
}
</style>

View File

@@ -0,0 +1,200 @@
<template>
<div class="border-bottom">
<div>
<button
type="button"
class="close float-right"
aria-label="Close"
@click="deleteField()"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="row">
<div class="col-md-3">
<div class="form-group">
<label>Field Type</label>
<select
class="form-control custom-select"
v-model.lazy="field.field_type"
>
<option value="text">Text Field</option>
<option value="boolean">Checkbox</option>
</select>
<small class="form-text text-muted"
>Type of field shown to the user</small
>
</div>
</div>
<div class="col-md-9">
<div class="form-group">
<label>Field Name</label>
<input type="text" class="form-control" v-model.lazy="field.name" />
<small class="form-text text-muted">Field name</small>
</div>
</div>
<div class="col-md-12">
<div class="form-group">
<label>Field Description</label>
<input
type="text"
class="form-control"
v-model.lazy="field.description"
/>
<small id="emailHelp" class="form-text text-muted"
>Field Description</small
>
</div>
</div>
<div class="col-md-12">
<div class="form-check">
<label class="form-check-label">
<input
class="form-check-input"
type="checkbox"
v-model.lazy="field.editable"
/>
Editable by user in profile
</label>
</div>
<div class="form-check">
<label class="form-check-label">
<input
class="form-check-input"
type="checkbox"
v-model.lazy="field.required"
/>
Required on registration
</label>
</div>
<div class="form-check">
<label class="form-check-label">
<input
class="form-check-input"
type="checkbox"
v-model.lazy="field.public"
/>
Shown on public profile
</label>
</div>
</div>
</div>
<div class="row pb-3">
<div class="col-md-12">
<div class="d-block">
<button
class="btn btn-sm btn-success btn-outlined float-right"
type="button"
@click="saveField()"
>
Save
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import CTFd from "core/CTFd";
import { ezToast } from "core/ezq";
export default {
props: {
index: Number,
initialField: Object
},
data: function() {
return {
field: this.initialField
};
},
methods: {
persistedField: function() {
// We're using Math.random() for unique IDs so new items have IDs < 1
// Real items will have an ID > 1
return this.field.id >= 1;
},
saveField: function() {
let body = this.field;
if (this.persistedField()) {
CTFd.fetch(`/api/v1/configs/fields/${this.field.id}`, {
method: "PATCH",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify(body)
})
.then(response => {
return response.json();
})
.then(response => {
if (response.success === true) {
this.field = response.data;
ezToast({
title: "Success",
body: "Field has been updated!",
delay: 1000
});
}
});
} else {
CTFd.fetch(`/api/v1/configs/fields`, {
method: "POST",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify(body)
})
.then(response => {
return response.json();
})
.then(response => {
if (response.success === true) {
this.field = response.data;
ezToast({
title: "Success",
body: "Field has been created!",
delay: 1000
});
}
});
}
},
deleteField: function() {
if (confirm("Are you sure you'd like to delete this field?")) {
if (this.persistedField()) {
CTFd.fetch(`/api/v1/configs/fields/${this.field.id}`, {
method: "DELETE",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
}
})
.then(response => {
return response.json();
})
.then(response => {
if (response.success === true) {
this.$emit("remove-field", this.index);
}
});
} else {
this.$emit("remove-field", this.index);
}
}
}
}
};
</script>
<style scoped></style>

View File

@@ -0,0 +1,82 @@
<template>
<div>
<!-- You can't use index as :key here b/c Vue is crazy -->
<!-- https://rimdev.io/the-v-for-key/ -->
<div class="mb-5" v-for="(field, index) in fields" :key="field.id">
<Field
:index="index"
:initialField.sync="fields[index]"
@remove-field="removeField"
/>
</div>
<div class="row">
<div class="col text-center">
<button
class="btn btn-sm btn-success btn-outlined m-auto"
type="button"
@click="addField()"
>
Add New Field
</button>
</div>
</div>
</div>
</template>
<script>
import CTFd from "core/CTFd";
import Field from "./Field.vue";
export default {
name: "FieldList",
components: {
Field
},
props: {
type: String
},
data: function() {
return {
fields: []
};
},
methods: {
loadFields: function() {
CTFd.fetch(`/api/v1/configs/fields?type=${this.type}`, {
method: "GET",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
}
})
.then(response => {
return response.json();
})
.then(response => {
this.fields = response.data;
});
},
addField: function() {
this.fields.push({
id: Math.random(),
type: this.type,
field_type: "text",
name: "",
description: "",
editable: false,
required: false,
public: false
});
},
removeField: function(index) {
this.fields.splice(index, 1);
console.log(this.fields);
}
},
created() {
this.loadFields();
}
};
</script>

View File

@@ -10,6 +10,8 @@ import { addFile, deleteFile } from "../challenges/files";
import { addTag, deleteTag } from "../challenges/tags";
import { addRequirement, deleteRequirement } from "../challenges/requirements";
import { bindMarkdownEditors } from "../styles";
import Vue from "vue/dist/vue.esm.browser";
import CommentBox from "../components/comments/CommentBox.vue";
import {
showHintModal,
editHint,
@@ -423,6 +425,14 @@ $(() => {
$("#flags-create-select").change(flagTypeSelect);
$(".edit-flag").click(editFlagModal);
// Insert CommentBox element
const commentBox = Vue.extend(CommentBox);
let vueContainer = document.createElement("div");
document.querySelector("#comment-box").appendChild(vueContainer);
new commentBox({
propsData: { type: "challenge", id: window.CHALLENGE_ID }
}).$mount(vueContainer);
$.get(CTFd.config.urlRoot + "/api/v1/challenges/types", function(response) {
const data = response.data;
loadChalTemplate(data["standard"]);

View File

@@ -9,6 +9,8 @@ import $ from "jquery";
import { ezQuery, ezProgressBar, ezAlert } from "core/ezq";
import CodeMirror from "codemirror";
import "codemirror/mode/htmlmixed/htmlmixed.js";
import Vue from "vue/dist/vue.esm.browser";
import FieldList from "../components/configs/fields/FieldList.vue";
function loadTimestamp(place, timestamp) {
if (typeof timestamp == "string") {
@@ -266,6 +268,17 @@ $(() => {
theme_settings_editor.refresh();
});
$(
"a[href='#legal'], a[href='#tos-config'], a[href='#privacy-policy-config']"
).on("shown.bs.tab", function(_e) {
$("#tos-config .CodeMirror").each(function(i, el) {
el.CodeMirror.refresh();
});
$("#privacy-policy-config .CodeMirror").each(function(i, el) {
el.CodeMirror.refresh();
});
});
$("#theme-settings-modal form").submit(function(e) {
e.preventDefault();
theme_settings_editor
@@ -360,4 +373,23 @@ $(() => {
$("#mail_username_password").toggle(this.checked);
})
.change();
// Insert FieldList element for users
const fieldList = Vue.extend(FieldList);
let userVueContainer = document.createElement("div");
document.querySelector("#user-field-list").appendChild(userVueContainer);
new fieldList({
propsData: {
type: "user"
}
}).$mount(userVueContainer);
// Insert FieldList element for teams
let teamVueContainer = document.createElement("div");
document.querySelector("#team-field-list").appendChild(teamVueContainer);
new fieldList({
propsData: {
type: "team"
}
}).$mount(teamVueContainer);
});

View File

@@ -6,6 +6,8 @@ import CTFd from "core/CTFd";
import CodeMirror from "codemirror";
import "codemirror/mode/htmlmixed/htmlmixed.js";
import { ezToast } from "core/ezq";
import Vue from "vue/dist/vue.esm.browser";
import CommentBox from "../components/comments/CommentBox.vue";
function submit_form() {
// Save the CodeMirror data to the Textarea
@@ -75,4 +77,14 @@ $(() => {
$(".preview-page").click(function() {
preview_page();
});
// Insert CommentBox element
if (window.PAGE_ID) {
const commentBox = Vue.extend(CommentBox);
let vueContainer = document.createElement("div");
document.querySelector("#comment-box").appendChild(vueContainer);
new commentBox({
propsData: { type: "page", id: window.PAGE_ID }
}).$mount(vueContainer);
}
});

View File

@@ -4,11 +4,26 @@ import CTFd from "core/CTFd";
import { htmlEntities } from "core/utils";
import { ezAlert, ezQuery, ezBadge } from "core/ezq";
import { createGraph, updateGraph } from "core/graphs";
import Vue from "vue/dist/vue.esm.browser";
import CommentBox from "../components/comments/CommentBox.vue";
function createTeam(event) {
event.preventDefault();
const params = $("#team-info-create-form").serializeJSON(true);
params.fields = [];
for (const property in params) {
if (property.match(/fields\[\d+\]/)) {
let field = {};
let id = parseInt(property.slice(7, -1));
field["field_id"] = id;
field["value"] = params[property];
params.fields.push(field);
delete params[property];
}
}
CTFd.fetch("/api/v1/teams", {
method: "POST",
credentials: "same-origin",
@@ -26,15 +41,17 @@ function createTeam(event) {
const team_id = response.data.id;
window.location = CTFd.config.urlRoot + "/admin/teams/" + team_id;
} else {
$("#team-info-form > #results").empty();
$("#team-info-create-form > #results").empty();
Object.keys(response.errors).forEach(function(key, _index) {
$("#team-info-form > #results").append(
$("#team-info-create-form > #results").append(
ezBadge({
type: "error",
body: response.errors[key]
})
);
const i = $("#team-info-form").find("input[name={0}]".format(key));
const i = $("#team-info-create-form").find(
"input[name={0}]".format(key)
);
const input = $(i);
input.addClass("input-filled-invalid");
input.removeClass("input-filled-valid");
@@ -45,7 +62,20 @@ function createTeam(event) {
function updateTeam(event) {
event.preventDefault();
const params = $("#team-info-edit-form").serializeJSON(true);
let params = $("#team-info-edit-form").serializeJSON(true);
params.fields = [];
for (const property in params) {
if (property.match(/fields\[\d+\]/)) {
let field = {};
let id = parseInt(property.slice(7, -1));
field["field_id"] = id;
field["value"] = params[property];
params.fields.push(field);
delete params[property];
}
}
CTFd.fetch("/api/v1/teams/" + window.TEAM_ID, {
method: "PATCH",
@@ -481,11 +511,30 @@ $(() => {
$("#team-info-edit-form").submit(updateTeam);
// Insert CommentBox element
const commentBox = Vue.extend(CommentBox);
let vueContainer = document.createElement("div");
document.querySelector("#comment-box").appendChild(vueContainer);
new commentBox({
propsData: { type: "team", id: window.TEAM_ID }
}).$mount(vueContainer);
let type, id, name, account_id;
({ type, id, name, account_id } = window.stats_data);
createGraphs(type, id, name, account_id);
setInterval(() => {
updateGraphs(type, id, name, account_id);
}, 300000);
let intervalId;
$("#team-statistics-modal").on("shown.bs.modal", function(_e) {
createGraphs(type, id, name, account_id);
intervalId = setInterval(() => {
updateGraphs(type, id, name, account_id);
}, 300000);
});
$("#team-statistics-modal").on("hidden.bs.modal", function(_e) {
clearInterval(intervalId);
});
$(".statistics-team").click(function(_event) {
$("#team-statistics-modal").modal("toggle");
});
});

View File

@@ -4,11 +4,26 @@ import CTFd from "core/CTFd";
import { htmlEntities } from "core/utils";
import { ezQuery, ezBadge } from "core/ezq";
import { createGraph, updateGraph } from "core/graphs";
import Vue from "vue/dist/vue.esm.browser";
import CommentBox from "../components/comments/CommentBox.vue";
function createUser(event) {
event.preventDefault();
const params = $("#user-info-create-form").serializeJSON(true);
params.fields = [];
for (const property in params) {
if (property.match(/fields\[\d+\]/)) {
let field = {};
let id = parseInt(property.slice(7, -1));
field["field_id"] = id;
field["value"] = params[property];
params.fields.push(field);
delete params[property];
}
}
// Move the notify value into a GET param
let url = "/api/v1/users";
let notify = params.notify;
@@ -55,6 +70,19 @@ function updateUser(event) {
event.preventDefault();
const params = $("#user-info-edit-form").serializeJSON(true);
params.fields = [];
for (const property in params) {
if (property.match(/fields\[\d+\]/)) {
let field = {};
let id = parseInt(property.slice(7, -1));
field["field_id"] = id;
field["value"] = params[property];
params.fields.push(field);
delete params[property];
}
}
CTFd.fetch("/api/v1/users/" + window.USER_ID, {
method: "PATCH",
credentials: "same-origin",
@@ -441,11 +469,30 @@ $(() => {
$("#user-info-edit-form").submit(updateUser);
$("#user-award-form").submit(awardUser);
// Insert CommentBox element
const commentBox = Vue.extend(CommentBox);
let vueContainer = document.createElement("div");
document.querySelector("#comment-box").appendChild(vueContainer);
new commentBox({
propsData: { type: "user", id: window.USER_ID }
}).$mount(vueContainer);
let type, id, name, account_id;
({ type, id, name, account_id } = window.stats_data);
createGraphs(type, id, name, account_id);
setInterval(() => {
updateGraphs(type, id, name, account_id);
}, 300000);
let intervalId;
$("#user-statistics-modal").on("shown.bs.modal", function(_e) {
createGraphs(type, id, name, account_id);
intervalId = setInterval(() => {
updateGraphs(type, id, name, account_id);
}, 300000);
});
$("#user-statistics-modal").on("hidden.bs.modal", function(_e) {
clearInterval(intervalId);
});
$(".statistics-user").click(function(_event) {
$("#user-statistics-modal").modal("toggle");
});
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
(window.webpackJsonp=window.webpackJsonp||[]).push([[0],{"./CTFd/themes/core/assets/js/helpers.js":function(e,r,o){Object.defineProperty(r,"__esModule",{value:!0}),r.default=void 0;var p=n(o("./node_modules/jquery/dist/jquery.js")),c=n(o("./CTFd/themes/core/assets/js/ezq.js")),t=o("./CTFd/themes/core/assets/js/utils.js");function n(e){return e&&e.__esModule?e:{default:e}}function f(e,r){return function(e){if(Array.isArray(e))return e}(e)||function(e,r){var o=[],t=!0,n=!1,s=void 0;try{for(var i,a=e[Symbol.iterator]();!(t=(i=a.next()).done)&&(o.push(i.value),!r||o.length!==r);t=!0);}catch(e){n=!0,s=e}finally{try{t||null==a.return||a.return()}finally{if(n)throw s}}return o}(e,r)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance")}()}var s={files:{upload:function(r,e,o){var t=window.CTFd;r instanceof p.default&&(r=r[0]);var n=new FormData(r);n.append("nonce",t.config.csrfNonce);for(var s=0,i=Object.entries(e);s<i.length;s++){var a=f(i[s],2),l=a[0],d=a[1];n.append(l,d)}var u=c.default.ezProgressBar({width:0,title:"Upload Progress"});p.default.ajax({url:t.config.urlRoot+"/api/v1/files",data:n,type:"POST",cache:!1,contentType:!1,processData:!1,xhr:function(){var e=p.default.ajaxSettings.xhr();return e.upload.onprogress=function(e){if(e.lengthComputable){var r=e.loaded/e.total*100;u=c.default.ezProgressBar({target:u,width:r})}},e},success:function(e){r.reset(),u=c.default.ezProgressBar({target:u,width:100}),setTimeout(function(){u.modal("hide")},500),o&&o(e)}})}},utils:{htmlEntities:t.htmlEntities,colorHash:t.colorHash,copyToClipboard:t.copyToClipboard},ezq:c.default};r.default=s},"./node_modules/markdown-it/lib/helpers/index.js":function(e,r,o){r.parseLinkLabel=o("./node_modules/markdown-it/lib/helpers/parse_link_label.js"),r.parseLinkDestination=o("./node_modules/markdown-it/lib/helpers/parse_link_destination.js"),r.parseLinkTitle=o("./node_modules/markdown-it/lib/helpers/parse_link_title.js")},"./node_modules/markdown-it/lib/helpers/parse_link_destination.js":function(e,r,o){var a=o("./node_modules/markdown-it/lib/common/utils.js").unescapeAll;e.exports=function(e,r,o){var t,n,s=r,i={ok:!1,pos:0,lines:0,str:""};if(60===e.charCodeAt(r)){for(r++;r<o;){if(10===(t=e.charCodeAt(r)))return i;if(62===t)return i.pos=r+1,i.str=a(e.slice(s+1,r)),i.ok=!0,i;92===t&&r+1<o?r+=2:r++}return i}for(n=0;r<o&&32!==(t=e.charCodeAt(r))&&!(t<32||127===t);)if(92===t&&r+1<o)r+=2;else{if(40===t&&n++,41===t){if(0===n)break;n--}r++}return s===r||0!==n||(i.str=a(e.slice(s,r)),i.lines=0,i.pos=r,i.ok=!0),i}},"./node_modules/markdown-it/lib/helpers/parse_link_label.js":function(e,r,o){e.exports=function(e,r,o){var t,n,s,i,a=-1,l=e.posMax,d=e.pos;for(e.pos=r+1,t=1;e.pos<l;){if(93===(s=e.src.charCodeAt(e.pos))&&0===--t){n=!0;break}if(i=e.pos,e.md.inline.skipToken(e),91===s)if(i===e.pos-1)t++;else if(o)return e.pos=d,-1}return n&&(a=e.pos),e.pos=d,a}},"./node_modules/markdown-it/lib/helpers/parse_link_title.js":function(e,r,o){var l=o("./node_modules/markdown-it/lib/common/utils.js").unescapeAll;e.exports=function(e,r,o){var t,n,s=0,i=r,a={ok:!1,pos:0,lines:0,str:""};if(o<=r)return a;if(34!==(n=e.charCodeAt(r))&&39!==n&&40!==n)return a;for(r++,40===n&&(n=41);r<o;){if((t=e.charCodeAt(r))===n)return a.pos=r+1,a.lines=s,a.str=l(e.slice(i+1,r)),a.ok=!0,a;10===t?s++:92===t&&r+1<o&&(r++,10===e.charCodeAt(r)&&s++),r++}return a}}}]);
(window.webpackJsonp=window.webpackJsonp||[]).push([[1],{"./CTFd/themes/core/assets/js/helpers.js":function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var d=r(n("./node_modules/jquery/dist/jquery.js")),p=r(n("./CTFd/themes/core/assets/js/ezq.js")),o=n("./CTFd/themes/core/assets/js/utils.js");function r(e){return e&&e.__esModule?e:{default:e}}function s(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function f(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=[],o=!0,r=!1,i=void 0;try{for(var s,a=e[Symbol.iterator]();!(o=(s=a.next()).done)&&(n.push(s.value),!t||n.length!==t);o=!0);}catch(e){r=!0,i=e}finally{try{o||null==a.return||a.return()}finally{if(r)throw i}}return n}(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance")}()}var i={files:{upload:function(t,e,n){var o=window.CTFd;t instanceof d.default&&(t=t[0]);var r=new FormData(t);r.append("nonce",o.config.csrfNonce);for(var i=0,s=Object.entries(e);i<s.length;i++){var a=f(s[i],2),l=a[0],c=a[1];r.append(l,c)}var u=p.default.ezProgressBar({width:0,title:"Upload Progress"});d.default.ajax({url:o.config.urlRoot+"/api/v1/files",data:r,type:"POST",cache:!1,contentType:!1,processData:!1,xhr:function(){var e=d.default.ajaxSettings.xhr();return e.upload.onprogress=function(e){if(e.lengthComputable){var t=e.loaded/e.total*100;u=p.default.ezProgressBar({target:u,width:t})}},e},success:function(e){t.reset(),u=p.default.ezProgressBar({target:u,width:100}),setTimeout(function(){u.modal("hide")},500),n&&n(e)}})}},comments:{get_comments:function(e){return window.CTFd.fetch("/api/v1/comments?"+d.default.param(e),{method:"GET",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()})},add_comment:function(e,t,n,o){var r=window.CTFd,i=function(t){for(var e=1;e<arguments.length;e++)if(e%2){var n=null!=arguments[e]?arguments[e]:{},o=Object.keys(n);"function"==typeof Object.getOwnPropertySymbols&&(o=o.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),o.forEach(function(e){s(t,e,n[e])})}else Object.defineProperties(t,Object.getOwnPropertyDescriptors(arguments[e]));return t}({content:e,type:t},n);r.fetch("/api/v1/comments",{method:"POST",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"},body:JSON.stringify(i)}).then(function(e){return e.json()}).then(function(e){o&&o(e)})},delete_comment:function(e){return window.CTFd.fetch("/api/v1/comments/".concat(e),{method:"DELETE",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()})}},utils:{htmlEntities:o.htmlEntities,colorHash:o.colorHash,copyToClipboard:o.copyToClipboard},ezq:p.default};t.default=i},"./node_modules/markdown-it/lib/helpers/index.js":function(e,t,n){t.parseLinkLabel=n("./node_modules/markdown-it/lib/helpers/parse_link_label.js"),t.parseLinkDestination=n("./node_modules/markdown-it/lib/helpers/parse_link_destination.js"),t.parseLinkTitle=n("./node_modules/markdown-it/lib/helpers/parse_link_title.js")},"./node_modules/markdown-it/lib/helpers/parse_link_destination.js":function(e,t,n){var a=n("./node_modules/markdown-it/lib/common/utils.js").unescapeAll;e.exports=function(e,t,n){var o,r,i=t,s={ok:!1,pos:0,lines:0,str:""};if(60===e.charCodeAt(t)){for(t++;t<n;){if(10===(o=e.charCodeAt(t)))return s;if(62===o)return s.pos=t+1,s.str=a(e.slice(i+1,t)),s.ok=!0,s;92===o&&t+1<n?t+=2:t++}return s}for(r=0;t<n&&32!==(o=e.charCodeAt(t))&&!(o<32||127===o);)if(92===o&&t+1<n)t+=2;else{if(40===o&&r++,41===o){if(0===r)break;r--}t++}return i===t||0!==r||(s.str=a(e.slice(i,t)),s.lines=0,s.pos=t,s.ok=!0),s}},"./node_modules/markdown-it/lib/helpers/parse_link_label.js":function(e,t,n){e.exports=function(e,t,n){var o,r,i,s,a=-1,l=e.posMax,c=e.pos;for(e.pos=t+1,o=1;e.pos<l;){if(93===(i=e.src.charCodeAt(e.pos))&&0===--o){r=!0;break}if(s=e.pos,e.md.inline.skipToken(e),91===i)if(s===e.pos-1)o++;else if(n)return e.pos=c,-1}return r&&(a=e.pos),e.pos=c,a}},"./node_modules/markdown-it/lib/helpers/parse_link_title.js":function(e,t,n){var l=n("./node_modules/markdown-it/lib/common/utils.js").unescapeAll;e.exports=function(e,t,n){var o,r,i=0,s=t,a={ok:!1,pos:0,lines:0,str:""};if(n<=t)return a;if(34!==(r=e.charCodeAt(t))&&39!==r&&40!==r)return a;for(t++,40===r&&(r=41);t<n;){if((o=e.charCodeAt(t))===r)return a.pos=t+1,a.lines=i,a.str=l(e.slice(s+1,t)),a.ok=!0,a;10===o?i++:92===o&&t+1<n&&(t++,10===e.charCodeAt(t)&&i++),t++}return a}}}]);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -147,7 +147,7 @@
/******/
/******/
/******/ // add entry module to deferred list
/******/ deferredModules.push(["./CTFd/themes/admin/assets/js/pages/challenges.js","helpers","vendor","default~pages/challenge~pages/challenges~pages/configs~pages/editor~pages/main~pages/notifications~p~d5a3cc0a"]);
/******/ deferredModules.push(["./CTFd/themes/admin/assets/js/pages/challenges.js","components","helpers","vendor","default~pages/challenge~pages/challenges~pages/configs~pages/editor~pages/main~pages/notifications~p~d5a3cc0a"]);
/******/ // run deferred modules when ready
/******/ return checkDeferredModules();
/******/ })

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -147,7 +147,7 @@
/******/
/******/
/******/ // add entry module to deferred list
/******/ deferredModules.push(["./CTFd/themes/admin/assets/js/pages/editor.js","helpers","vendor","default~pages/challenge~pages/challenges~pages/configs~pages/editor~pages/main~pages/notifications~p~d5a3cc0a"]);
/******/ deferredModules.push(["./CTFd/themes/admin/assets/js/pages/editor.js","components","helpers","vendor","default~pages/challenge~pages/challenges~pages/configs~pages/editor~pages/main~pages/notifications~p~d5a3cc0a"]);
/******/ // run deferred modules when ready
/******/ return checkDeferredModules();
/******/ })
@@ -162,7 +162,7 @@
/***/ (function(module, exports, __webpack_require__) {
;
eval("\n\n__webpack_require__(/*! ./main */ \"./CTFd/themes/admin/assets/js/pages/main.js\");\n\nvar _styles = __webpack_require__(/*! ../styles */ \"./CTFd/themes/admin/assets/js/styles.js\");\n\n__webpack_require__(/*! core/utils */ \"./CTFd/themes/core/assets/js/utils.js\");\n\nvar _jquery = _interopRequireDefault(__webpack_require__(/*! jquery */ \"./node_modules/jquery/dist/jquery.js\"));\n\nvar _CTFd = _interopRequireDefault(__webpack_require__(/*! core/CTFd */ \"./CTFd/themes/core/assets/js/CTFd.js\"));\n\nvar _codemirror = _interopRequireDefault(__webpack_require__(/*! codemirror */ \"./node_modules/codemirror/lib/codemirror.js\"));\n\n__webpack_require__(/*! codemirror/mode/htmlmixed/htmlmixed.js */ \"./node_modules/codemirror/mode/htmlmixed/htmlmixed.js\");\n\nvar _ezq = __webpack_require__(/*! core/ezq */ \"./CTFd/themes/core/assets/js/ezq.js\");\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\nfunction submit_form() {\n // Save the CodeMirror data to the Textarea\n window.editor.save();\n var params = (0, _jquery.default)(\"#page-edit\").serializeJSON();\n var target = \"/api/v1/pages\";\n var method = \"POST\";\n var part = window.location.pathname.split(\"/\").pop();\n\n if (part !== \"new\") {\n target += \"/\" + part;\n method = \"PATCH\";\n }\n\n _CTFd.default.fetch(target, {\n method: method,\n credentials: \"same-origin\",\n headers: {\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\"\n },\n body: JSON.stringify(params)\n }).then(function (response) {\n return response.json();\n }).then(function (response) {\n if (method === \"PATCH\" && response.success) {\n (0, _ezq.ezToast)({\n title: \"Saved\",\n body: \"Your changes have been saved\"\n });\n } else {\n window.location = _CTFd.default.config.urlRoot + \"/admin/pages/\" + response.data.id;\n }\n });\n}\n\nfunction preview_page() {\n window.editor.save(); // Save the CodeMirror data to the Textarea\n\n (0, _jquery.default)(\"#page-edit\").attr(\"action\", _CTFd.default.config.urlRoot + \"/admin/pages/preview\");\n (0, _jquery.default)(\"#page-edit\").attr(\"target\", \"_blank\");\n (0, _jquery.default)(\"#page-edit\").submit();\n}\n\n(0, _jquery.default)(function () {\n window.editor = _codemirror.default.fromTextArea(document.getElementById(\"admin-pages-editor\"), {\n lineNumbers: true,\n lineWrapping: true,\n mode: \"htmlmixed\",\n htmlMode: true\n });\n (0, _jquery.default)(\"#media-button\").click(function (_e) {\n (0, _styles.showMediaLibrary)(window.editor);\n });\n (0, _jquery.default)(\"#save-page\").click(function (e) {\n e.preventDefault();\n submit_form();\n });\n (0, _jquery.default)(\".preview-page\").click(function () {\n preview_page();\n });\n});\n\n//# sourceURL=webpack:///./CTFd/themes/admin/assets/js/pages/editor.js?");
eval("\n\n__webpack_require__(/*! ./main */ \"./CTFd/themes/admin/assets/js/pages/main.js\");\n\nvar _styles = __webpack_require__(/*! ../styles */ \"./CTFd/themes/admin/assets/js/styles.js\");\n\n__webpack_require__(/*! core/utils */ \"./CTFd/themes/core/assets/js/utils.js\");\n\nvar _jquery = _interopRequireDefault(__webpack_require__(/*! jquery */ \"./node_modules/jquery/dist/jquery.js\"));\n\nvar _CTFd = _interopRequireDefault(__webpack_require__(/*! core/CTFd */ \"./CTFd/themes/core/assets/js/CTFd.js\"));\n\nvar _codemirror = _interopRequireDefault(__webpack_require__(/*! codemirror */ \"./node_modules/codemirror/lib/codemirror.js\"));\n\n__webpack_require__(/*! codemirror/mode/htmlmixed/htmlmixed.js */ \"./node_modules/codemirror/mode/htmlmixed/htmlmixed.js\");\n\nvar _ezq = __webpack_require__(/*! core/ezq */ \"./CTFd/themes/core/assets/js/ezq.js\");\n\nvar _vueEsm = _interopRequireDefault(__webpack_require__(/*! vue/dist/vue.esm.browser */ \"./node_modules/vue/dist/vue.esm.browser.js\"));\n\nvar _CommentBox = _interopRequireDefault(__webpack_require__(/*! ../components/comments/CommentBox.vue */ \"./CTFd/themes/admin/assets/js/components/comments/CommentBox.vue\"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\nfunction submit_form() {\n // Save the CodeMirror data to the Textarea\n window.editor.save();\n var params = (0, _jquery.default)(\"#page-edit\").serializeJSON();\n var target = \"/api/v1/pages\";\n var method = \"POST\";\n var part = window.location.pathname.split(\"/\").pop();\n\n if (part !== \"new\") {\n target += \"/\" + part;\n method = \"PATCH\";\n }\n\n _CTFd.default.fetch(target, {\n method: method,\n credentials: \"same-origin\",\n headers: {\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\"\n },\n body: JSON.stringify(params)\n }).then(function (response) {\n return response.json();\n }).then(function (response) {\n if (method === \"PATCH\" && response.success) {\n (0, _ezq.ezToast)({\n title: \"Saved\",\n body: \"Your changes have been saved\"\n });\n } else {\n window.location = _CTFd.default.config.urlRoot + \"/admin/pages/\" + response.data.id;\n }\n });\n}\n\nfunction preview_page() {\n window.editor.save(); // Save the CodeMirror data to the Textarea\n\n (0, _jquery.default)(\"#page-edit\").attr(\"action\", _CTFd.default.config.urlRoot + \"/admin/pages/preview\");\n (0, _jquery.default)(\"#page-edit\").attr(\"target\", \"_blank\");\n (0, _jquery.default)(\"#page-edit\").submit();\n}\n\n(0, _jquery.default)(function () {\n window.editor = _codemirror.default.fromTextArea(document.getElementById(\"admin-pages-editor\"), {\n lineNumbers: true,\n lineWrapping: true,\n mode: \"htmlmixed\",\n htmlMode: true\n });\n (0, _jquery.default)(\"#media-button\").click(function (_e) {\n (0, _styles.showMediaLibrary)(window.editor);\n });\n (0, _jquery.default)(\"#save-page\").click(function (e) {\n e.preventDefault();\n submit_form();\n });\n (0, _jquery.default)(\".preview-page\").click(function () {\n preview_page();\n }); // Insert CommentBox element\n\n if (window.PAGE_ID) {\n var commentBox = _vueEsm.default.extend(_CommentBox.default);\n\n var vueContainer = document.createElement(\"div\");\n document.querySelector(\"#comment-box\").appendChild(vueContainer);\n new commentBox({\n propsData: {\n type: \"page\",\n id: window.PAGE_ID\n }\n }).$mount(vueContainer);\n }\n});\n\n//# sourceURL=webpack:///./CTFd/themes/admin/assets/js/pages/editor.js?");
/***/ })

File diff suppressed because one or more lines are too long

View File

@@ -147,7 +147,7 @@
/******/
/******/
/******/ // add entry module to deferred list
/******/ deferredModules.push(["./CTFd/themes/admin/assets/js/pages/main.js","helpers","vendor","default~pages/challenge~pages/challenges~pages/configs~pages/editor~pages/main~pages/notifications~p~d5a3cc0a"]);
/******/ deferredModules.push(["./CTFd/themes/admin/assets/js/pages/main.js","components","helpers","vendor","default~pages/challenge~pages/challenges~pages/configs~pages/editor~pages/main~pages/notifications~p~d5a3cc0a"]);
/******/ // run deferred modules when ready
/******/ return checkDeferredModules();
/******/ })

File diff suppressed because one or more lines are too long

View File

@@ -147,7 +147,7 @@
/******/
/******/
/******/ // add entry module to deferred list
/******/ deferredModules.push(["./CTFd/themes/admin/assets/js/pages/notifications.js","helpers","vendor","default~pages/challenge~pages/challenges~pages/configs~pages/editor~pages/main~pages/notifications~p~d5a3cc0a"]);
/******/ deferredModules.push(["./CTFd/themes/admin/assets/js/pages/notifications.js","components","helpers","vendor","default~pages/challenge~pages/challenges~pages/configs~pages/editor~pages/main~pages/notifications~p~d5a3cc0a"]);
/******/ // run deferred modules when ready
/******/ return checkDeferredModules();
/******/ })

File diff suppressed because one or more lines are too long

View File

@@ -147,7 +147,7 @@
/******/
/******/
/******/ // add entry module to deferred list
/******/ deferredModules.push(["./CTFd/themes/admin/assets/js/pages/pages.js","helpers","vendor","default~pages/challenge~pages/challenges~pages/configs~pages/editor~pages/main~pages/notifications~p~d5a3cc0a"]);
/******/ deferredModules.push(["./CTFd/themes/admin/assets/js/pages/pages.js","components","helpers","vendor","default~pages/challenge~pages/challenges~pages/configs~pages/editor~pages/main~pages/notifications~p~d5a3cc0a"]);
/******/ // run deferred modules when ready
/******/ return checkDeferredModules();
/******/ })

File diff suppressed because one or more lines are too long

View File

@@ -147,7 +147,7 @@
/******/
/******/
/******/ // add entry module to deferred list
/******/ deferredModules.push(["./CTFd/themes/admin/assets/js/pages/reset.js","helpers","vendor","default~pages/challenge~pages/challenges~pages/configs~pages/editor~pages/main~pages/notifications~p~d5a3cc0a"]);
/******/ deferredModules.push(["./CTFd/themes/admin/assets/js/pages/reset.js","components","helpers","vendor","default~pages/challenge~pages/challenges~pages/configs~pages/editor~pages/main~pages/notifications~p~d5a3cc0a"]);
/******/ // run deferred modules when ready
/******/ return checkDeferredModules();
/******/ })

File diff suppressed because one or more lines are too long

View File

@@ -147,7 +147,7 @@
/******/
/******/
/******/ // add entry module to deferred list
/******/ deferredModules.push(["./CTFd/themes/admin/assets/js/pages/scoreboard.js","helpers","vendor","default~pages/challenge~pages/challenges~pages/configs~pages/editor~pages/main~pages/notifications~p~d5a3cc0a"]);
/******/ deferredModules.push(["./CTFd/themes/admin/assets/js/pages/scoreboard.js","components","helpers","vendor","default~pages/challenge~pages/challenges~pages/configs~pages/editor~pages/main~pages/notifications~p~d5a3cc0a"]);
/******/ // run deferred modules when ready
/******/ return checkDeferredModules();
/******/ })

File diff suppressed because one or more lines are too long

View File

@@ -147,7 +147,7 @@
/******/
/******/
/******/ // add entry module to deferred list
/******/ deferredModules.push(["./CTFd/themes/admin/assets/js/pages/statistics.js","helpers","echarts","vendor","default~pages/challenge~pages/challenges~pages/configs~pages/editor~pages/main~pages/notifications~p~d5a3cc0a"]);
/******/ deferredModules.push(["./CTFd/themes/admin/assets/js/pages/statistics.js","components","helpers","echarts","vendor","default~pages/challenge~pages/challenges~pages/configs~pages/editor~pages/main~pages/notifications~p~d5a3cc0a"]);
/******/ // run deferred modules when ready
/******/ return checkDeferredModules();
/******/ })

File diff suppressed because one or more lines are too long

View File

@@ -147,7 +147,7 @@
/******/
/******/
/******/ // add entry module to deferred list
/******/ deferredModules.push(["./CTFd/themes/admin/assets/js/pages/submissions.js","helpers","vendor","default~pages/challenge~pages/challenges~pages/configs~pages/editor~pages/main~pages/notifications~p~d5a3cc0a"]);
/******/ deferredModules.push(["./CTFd/themes/admin/assets/js/pages/submissions.js","components","helpers","vendor","default~pages/challenge~pages/challenges~pages/configs~pages/editor~pages/main~pages/notifications~p~d5a3cc0a"]);
/******/ // run deferred modules when ready
/******/ return checkDeferredModules();
/******/ })

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -147,7 +147,7 @@
/******/
/******/
/******/ // add entry module to deferred list
/******/ deferredModules.push(["./CTFd/themes/admin/assets/js/pages/teams.js","helpers","vendor","default~pages/challenge~pages/challenges~pages/configs~pages/editor~pages/main~pages/notifications~p~d5a3cc0a"]);
/******/ deferredModules.push(["./CTFd/themes/admin/assets/js/pages/teams.js","components","helpers","vendor","default~pages/challenge~pages/challenges~pages/configs~pages/editor~pages/main~pages/notifications~p~d5a3cc0a"]);
/******/ // run deferred modules when ready
/******/ return checkDeferredModules();
/******/ })

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -147,7 +147,7 @@
/******/
/******/
/******/ // add entry module to deferred list
/******/ deferredModules.push(["./CTFd/themes/admin/assets/js/pages/users.js","helpers","vendor","default~pages/challenge~pages/challenges~pages/configs~pages/editor~pages/main~pages/notifications~p~d5a3cc0a"]);
/******/ deferredModules.push(["./CTFd/themes/admin/assets/js/pages/users.js","components","helpers","vendor","default~pages/challenge~pages/challenges~pages/configs~pages/editor~pages/main~pages/notifications~p~d5a3cc0a"]);
/******/ // run deferred modules when ready
/******/ return checkDeferredModules();
/******/ })

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -141,6 +141,7 @@
<script defer src="{{ url_for('views.themes', theme='admin', path='js/vendor.bundle.js') }}"></script>
<script defer src="{{ url_for('views.themes', theme='admin', path='js/core.js') }}"></script>
<script defer src="{{ url_for('views.themes', theme='admin', path='js/helpers.js') }}"></script>
<script defer src="{{ url_for('views.themes', theme='admin', path='js/components.js') }}"></script>
{% block entrypoint %}
<script defer src="{{ url_for('views.themes', theme='admin', path='js/pages/main.js') }}"></script>

View File

@@ -45,7 +45,7 @@
<div class="row">
<div class="col-md-6">
<nav class="nav nav-tabs nav-fill" id="challenge-properties" role="tablist">
<a class="nav-item nav-link active" data-toggle="tab" href="#solves" role="tab" >Solves</a>
<a class="nav-item nav-link active" data-toggle="tab" href="#comments" role="tab" >Comments</a>
<a class="nav-item nav-link" data-toggle="tab" href="#flags" role="tab">Flags</a>
<a class="nav-item nav-link" data-toggle="tab" href="#files" role="tab">Files</a>
<a class="nav-item nav-link" data-toggle="tab" href="#tags" role="tab">Tags</a>
@@ -54,11 +54,14 @@
</nav>
<div class="tab-content" id="nav-tabContent">
<div class="tab-pane fade show active" id="solves" role="tabpanel">
<div class="tab-pane fade show active" id="comments" role="tabpanel">
<div class="row">
<div class="col-md-12">
<h3 class="text-center py-3 d-block">Solves</h3>
{% include "admin/modals/challenges/solves.html" %}
<h3 class="text-center py-3 d-block">
Comments
</h3>
<div id="comment-box">
</div>
</div>
</div>
</div>

View File

@@ -23,6 +23,9 @@
<li class="nav-item">
<a class="nav-link rounded-0" href="#accounts" role="tab" data-toggle="tab">Accounts</a>
</li>
<li class="nav-item">
<a class="nav-link rounded-0" href="#fields" role="tab" data-toggle="tab">Custom Fields</a>
</li>
<li class="nav-item">
<a class="nav-link rounded-0" href="#mlc" role="tab" data-toggle="tab">MajorLeagueCyber</a>
</li>
@@ -35,6 +38,9 @@
<li class="nav-item">
<a class="nav-link rounded-0" href="#ctftime" role="tab" data-toggle="tab">Time</a>
</li>
<li class="nav-item">
<a class="nav-link rounded-0" href="#legal" role="tab" data-toggle="tab">Legal</a>
</li>
<li class="nav-item">
<a class="nav-link rounded-0" href="#backup" role="tab" data-toggle="tab">Backup</a>
</li>
@@ -61,6 +67,8 @@
{% include "admin/configs/accounts.html" %}
{% include "admin/configs/fields.html" %}
{% include "admin/configs/mlc.html" %}
{% include "admin/configs/settings.html" %}
@@ -69,6 +77,8 @@
{% include "admin/configs/time.html" %}
{% include "admin/configs/legal.html" %}
{% include "admin/configs/backup.html" %}
</div>
</div>

View File

@@ -0,0 +1,41 @@
<div role="tabpanel" class="tab-pane config-section" id="fields">
<form method="POST" autocomplete="off" class="w-100">
<h5>Custom Fields</h5>
<small class="form-text text-muted">
Add custom fields to get additional data from your participants
</small>
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item active">
<a class="nav-link active" href="#user-fields" role="tab" data-toggle="tab">
Users
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#team-fields" role="tab" data-toggle="tab">
Teams
</a>
</li>
</ul>
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="user-fields">
<div class="col-md-12 py-3">
<small>Custom user fields are shown during registration. Users can optionally edit these fields in their profile.</small>
</div>
<div id="user-field-list" class="pt-3">
</div>
</div>
<div role="tabpanel" class="tab-pane" id="team-fields">
<div class="col-md-12 py-3">
<small>Custom team fields are shown during team creation. Team captains can optionally edit these fields in the team profile.</small>
</div>
<div id="team-field-list" class="pt-3">
</div>
</div>
</div>
</form>
</div>

View File

@@ -0,0 +1,56 @@
<div role="tabpanel" class="tab-pane config-section" id="legal">
{% with form = Forms.config.LegalSettingsForm(tos_url=tos_url, tos_text=tos_text, privacy_url=privacy_url, privacy_text=privacy_text) %}
<form method="POST" autocomplete="off" class="w-100">
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a class="nav-link active" href="#tos-config" role="tab" data-toggle="tab">
Terms of Service
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#privacy-policy-config" role="tab" data-toggle="tab">
Privacy Policy
</a>
</li>
</ul>
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="tos-config">
<div class="form-group">
{{ form.tos_url.label }}
{{ form.tos_url(class="form-control") }}
<small class="form-text text-muted">
{{ form.tos_url.description }}
</small>
</div>
<div class="form-group">
{{ form.tos_text.label }}
<small class="form-text text-muted">
{{ form.tos_text.description }}
</small>
{{ form.tos_text(class="form-control markdown", rows=15) }}
</div>
</div>
<div role="tabpanel" class="tab-pane" id="privacy-policy-config">
<div class="form-group">
{{ form.privacy_url.label }}
{{ form.privacy_url(class="form-control") }}
<small class="form-text text-muted">
{{ form.privacy_url.description }}
</small>
</div>
<div class="form-group">
{{ form.privacy_text.label }}
<small class="form-text text-muted">
{{ form.privacy_text.description }}
</small>
{{ form.privacy_text(class="form-control markdown", rows=15) }}
</div>
</div>
</div>
{{ form.submit(class="btn btn-md btn-primary float-right") }}
</form>
{% endwith %}
</div>

View File

@@ -117,10 +117,25 @@
{% endwith %}
</div>
</div>
{% if page is defined %}
<div class="row min-vh-25 pt-5">
<div class="col-md-10 offset-md-1">
<h3 class="text-center py-3 d-block">
Comments
</h3>
<div id="comment-box">
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
var PAGE_ID = {{ page.id if page is defined else "null"}};
</script>
{% endblock %}
{% block entrypoint %}

View File

@@ -0,0 +1,46 @@
{% macro render_extra_fields(fields, show_labels=True, show_optionals=True, show_descriptions=True) -%}
{% for field in fields %}
<div class="form-group">
{% if field.field_type == "text" %}
{% if show_labels %}
{{ field.label }}
{% endif %}
{% if show_optionals %}
{% if field.flags.required is false %}
<small class="float-right text-muted">Optional</small>
{% endif %}
{% endif %}
{{ field(class="form-control") }}
{% if show_descriptions %}
{% if field.description %}
<small class="form-text text-muted">
{{ field.description }}
</small>
{% endif %}
{% endif %}
{% elif field.field_type == "boolean" %}
<div class="form-check">
{{ field(class="form-check-input") }}
{{ field.label(class="form-check-label") }}
{% if show_optionals %}
{% if field.flags.required is false %}
<sup class="text-muted">Optional</sup>
{% endif %}
{% endif %}
</div>
{% if show_descriptions %}
{% if field.description %}
<small class="form-text text-muted">
{{ field.description }}
</small>
{% endif %}
{% endif %}
{% endif %}
</div>
{% endfor %}
{%- endmacro %}

View File

@@ -1,29 +1,36 @@
{% with form = Forms.teams.TeamEditForm() %}
{% with form = Forms.teams.TeamCreateForm() %}
{% from "admin/macros/forms.html" import render_extra_fields %}
<form id="team-info-create-form" method="POST">
<div class="form-group">
<b>{{ form.name.label }}</b>
{{ form.name.label }}
{{ form.name(class="form-control") }}
</div>
<div class="form-group">
<b>{{ form.email.label }}</b>
{{ form.email.label }}
{{ form.email(class="form-control") }}
</div>
<div class="form-group">
<b>{{ form.password.label }}</b>
{{ form.password.label }}
{{ form.password(class="form-control") }}
</div>
<div class="form-group">
<b>{{ form.website.label }}</b>
{{ form.website.label }}
<small class="float-right text-muted align-text-bottom">Optional</small>
{{ form.website(class="form-control") }}
</div>
<div class="form-group">
<b>{{ form.affiliation.label }}</b>
{{ form.affiliation.label }}
<small class="float-right text-muted align-text-bottom">Optional</small>
{{ form.affiliation(class="form-control") }}
</div>
<div class="form-group">
<b>{{ form.country.label }}</b>
{{ form.country.label }}
<small class="float-right text-muted align-text-bottom">Optional</small>
{{ form.country(class="form-control custom-select") }}
</div>
{{ render_extra_fields(form.extra) }}
<div class="form-group">
<div class="form-check form-check-inline">
{{ form.hidden(class="form-check-input") }}

View File

@@ -1,4 +1,5 @@
{% with form = Forms.teams.TeamCreateForm(obj=team) %}
{% with form = Forms.teams.TeamEditForm(obj=team) %}
{% from "admin/macros/forms.html" import render_extra_fields %}
<form id="team-info-edit-form" method="POST">
<div class="form-group">
{{ form.name.label }}
@@ -24,6 +25,9 @@
{{ form.country.label }}
{{ form.country(class="form-control custom-select") }}
</div>
{{ render_extra_fields(form.extra) }}
<div class="form-group">
<div class="form-check form-check-inline">
{{ form.hidden(class="form-check-input") }}

View File

@@ -0,0 +1,23 @@
<div class="row min-vh-25">
{% if solves %}
<div id="keys-pie-graph" class="col-md-6 d-flex align-items-center">
<div class="text-center w-100">
<i class="fas fa-circle-notch fa-spin fa-3x fa-fw spinner"></i>
</div>
</div>
<div id="categories-pie-graph" class="col-md-6 d-flex align-items-center">
<div class="text-center w-100">
<i class="fas fa-circle-notch fa-spin fa-3x fa-fw spinner"></i>
</div>
</div>
<div id="score-graph" class="col-md-12 d-flex align-items-center">
<div class="text-center w-100">
<i class="fas fa-circle-notch fa-spin fa-3x fa-fw spinner"></i>
</div>
</div>
{% else %}
<h3 class="opacity-50 text-center w-100 justify-content-center align-self-center">
No data yet
</h3>
{% endif %}
</div>

View File

@@ -1,4 +1,5 @@
{% with form = Forms.users.UserCreateForm() %}
{% from "admin/macros/forms.html" import render_extra_fields %}
<form id="user-info-create-form" method="POST">
<div class="form-group">
{{ form.name.label }}
@@ -14,16 +15,22 @@
</div>
<div class="form-group">
{{ form.website.label }}
<small class="float-right text-muted align-text-bottom">Optional</small>
{{ form.website(class="form-control") }}
</div>
<div class="form-group">
{{ form.affiliation.label }}
<small class="float-right text-muted align-text-bottom">Optional</small>
{{ form.affiliation(class="form-control") }}
</div>
<div class="form-group">
{{ form.country.label }}
<small class="float-right text-muted align-text-bottom">Optional</small>
{{ form.country(class="form-control custom-select") }}
</div>
{{ render_extra_fields(form.extra) }}
<div class="form-group">
<div class="form-check form-check-inline">
{{ form.type(class="form-control form-inline custom-select", id="type-select") }}

View File

@@ -1,4 +1,5 @@
{% with form = Forms.users.UserEditForm(obj=user) %}
{% from "admin/macros/forms.html" import render_extra_fields %}
<form id="user-info-edit-form">
<div class="form-group">
{{ form.name.label }}
@@ -24,6 +25,9 @@
{{ form.country.label }}
{{ form.country(class="form-control custom-select") }}
</div>
{{ render_extra_fields(form.extra) }}
<div class="form-group">
<div class="form-check form-check-inline">
{{ form.type(class="form-control form-inline custom-select", id="type-select") }}

View File

@@ -0,0 +1,23 @@
<div class="row min-vh-25">
{% if solves %}
<div id="keys-pie-graph" class="col-md-6 d-flex align-items-center">
<div class="text-center w-100">
<i class="fas fa-circle-notch fa-spin fa-3x fa-fw spinner"></i>
</div>
</div>
<div id="categories-pie-graph" class="col-md-6 d-flex align-items-center">
<div class="text-center w-100">
<i class="fas fa-circle-notch fa-spin fa-3x fa-fw spinner"></i>
</div>
</div>
<div id="score-graph" class="col-md-12 d-flex align-items-center">
<div class="text-center w-100">
<i class="fas fa-circle-notch fa-spin fa-3x fa-fw spinner"></i>
</div>
</div>
{% else %}
<h3 class="opacity-50 text-center w-100 justify-content-center align-self-center">
No data yet
</h3>
{% endif %}
</div>

View File

@@ -20,6 +20,22 @@
</div>
</div>
<div id="team-statistics-modal" class="modal fade">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-action text-center w-100">Team Statistics</h2>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body clearfix">
{% include "admin/modals/teams/statistics.html" %}
</div>
</div>
</div>
</div>
<div id="team-captain-modal" class="modal fade">
<div class="modal-dialog">
<div class="modal-content">
@@ -121,6 +137,12 @@
</h3>
{% endif %}
{% for field in team.get_fields(admin=true) %}
<h3 class="d-block">
{{ field.name }}: {{ field.value }}
</h3>
{% endfor %}
<h2 class="text-center">{{ members | length }} members</h2>
<h3 id="team-place" class="text-center">
{% if place %}
@@ -140,6 +162,10 @@
<i class="btn-fa fas fa-pencil-alt fa-2x px-2" data-toggle="tooltip" data-placement="top"
title="Edit Team"></i>
</a>
<a class="statistics-team text-dark">
<i class="btn-fa fas fa-chart-pie fa-2x px-2" data-toggle="tooltip" data-placement="top"
title="Team Statistics"></i>
</a>
<a class="edit-captain text-dark">
<i class="btn-fa fas fa-user-tag fa-2x px-2" data-toggle="tooltip" data-placement="top"
title="Choose Captain"></i>
@@ -157,7 +183,7 @@
<i class="btn-fa fas fa-network-wired fa-2x px-2" data-toggle="tooltip" data-placement="top" title="IP Addresses"></i>
</a>
{% if team.website %}
<a href="{{ team.website }}" target="_blank" class="text-dark">
<a href="{{ team.website }}" target="_blank" class="text-dark" rel="noopener">
<i class="btn-fa fas fa-external-link-alt fa-2x px-2" data-toggle="tooltip" data-placement="top"
title="{{ team.website }}" aria-hidden="true"></i>
</a>
@@ -167,31 +193,7 @@
</div>
<div class="container">
<div class="row min-vh-25">
{% if solves %}
<div id="keys-pie-graph" class="col-md-6 d-flex align-items-center">
<div class="text-center w-100">
<i class="fas fa-circle-notch fa-spin fa-3x fa-fw spinner"></i>
</div>
</div>
<div id="categories-pie-graph" class="col-md-6 d-flex align-items-center">
<div class="text-center w-100">
<i class="fas fa-circle-notch fa-spin fa-3x fa-fw spinner"></i>
</div>
</div>
<div id="score-graph" class="col-md-12 d-flex align-items-center">
<div class="text-center w-100">
<i class="fas fa-circle-notch fa-spin fa-3x fa-fw spinner"></i>
</div>
</div>
{% else %}
<h3 class="opacity-50 text-center w-100 justify-content-center align-self-center">
No solves yet
</h3>
{% endif %}
</div>
<div class="row pt-5 min-vh-25">
<div class="row min-vh-25 pt-5 pb-5">
<div class="col-md-12">
<table class="table table-striped">
<h3 class="text-center">Team Members</h3>
@@ -249,7 +251,7 @@
aria-controls="nav-missing" aria-selected="false">Missing</a>
</nav>
<div class="tab-content min-vh-50" id="nav-tabContent">
<div class="tab-content min-vh-50 pb-5" id="nav-tabContent">
<div class="tab-pane fade show active" id="nav-solves" role="tabpanel" aria-labelledby="nav-solves-tab">
<h3 class="text-center pt-5 d-block">Solves</h3>
<div class="row">
@@ -489,6 +491,13 @@
</div>
</div>
</div>
<div class="row min-vh-25 pt-5">
<div class="col-md-10 offset-md-1">
<div id="comment-box">
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -99,7 +99,7 @@
</a>
{% endif %}
{% if team.website %}
<a href="{{ team.website }}" target="_blank" class="badge badge-info">
<a href="{{ team.website }}" target="_blank" class="badge badge-info" rel="noopener">
<i class="btn-fa fas fa-external-link-alt" data-toggle="tooltip" data-placement="top"
title="{{ team.website }}" aria-hidden="true"></i>
</a>

View File

@@ -20,6 +20,22 @@
</div>
</div>
<div id="user-statistics-modal" class="modal fade">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-action text-center w-100">User Statistics</h2>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body clearfix">
{% include "admin/modals/users/statistics.html" %}
</div>
</div>
</div>
</div>
<div id="user-award-modal" class="modal fade">
<div class="modal-dialog">
<div class="modal-content">
@@ -116,6 +132,12 @@
</h2>
{% endif %}
{% for field in user.get_fields(admin=true) %}
<h3 class="d-block">
{{ field.name }}: {{ field.value }}
</h3>
{% endfor %}
<h3 id="team-place" class="text-center">
{% if place %}
{{ place }}
@@ -133,6 +155,9 @@
<a class="edit-user text-dark">
<i class="btn-fa fas fa-user-edit fa-2x px-2" data-toggle="tooltip" data-placement="top" title="Edit User"></i>
</a>
<a class="statistics-user text-dark">
<i class="btn-fa fas fa-chart-pie fa-2x px-2" data-toggle="tooltip" data-placement="top" title="User Statistics"></i>
</a>
<a class="award-user text-dark">
<i class="btn-fa fas fa-trophy fa-2x px-2" data-toggle="tooltip" data-placement="top" title="Award User"></i>
</a>
@@ -148,7 +173,7 @@
<i class="btn-fa fas fa-network-wired fa-2x px-2" data-toggle="tooltip" data-placement="top" title="IP Addresses"></i>
</a>
{% if user.website %}
<a href="{{ user.website }}" target="_blank" class="text-decoration-none text-dark">
<a href="{{ user.website }}" target="_blank" class="text-decoration-none text-dark" rel="noopener">
<i class="btn-fa fas fa-external-link-alt fa-2x px-2" data-toggle="tooltip" data-placement="top"
title="{{ user.website }}" aria-hidden="true"></i>
</a>
@@ -158,29 +183,6 @@
</div>
<div class="container">
<div class="row min-vh-25">
{% if solves %}
<div id="keys-pie-graph" class="col-md-6 d-flex align-items-center">
<div class="text-center w-100">
<i class="fas fa-circle-notch fa-spin fa-3x fa-fw spinner"></i>
</div>
</div>
<div id="categories-pie-graph" class="col-md-6 d-flex align-items-center">
<div class="text-center w-100">
<i class="fas fa-circle-notch fa-spin fa-3x fa-fw spinner"></i>
</div>
</div>
<div id="score-graph" class="col-md-12 d-flex align-items-center">
<div class="text-center w-100">
<i class="fas fa-circle-notch fa-spin fa-3x fa-fw spinner"></i>
</div>
</div>
{% else %}
<h3 class="opacity-50 text-center w-100 justify-content-center align-self-center">No solves yet</h3>
{% endif %}
</div>
<nav class="nav nav-tabs nav-fill pt-5" id="myTab" role="tablist">
<a class="nav-item nav-link active" id="nav-solves-tab" data-toggle="tab" href="#nav-solves" role="tab"
aria-controls="nav-solves" aria-selected="true">Solves</a>
@@ -195,7 +197,7 @@
aria-controls="nav-missing" aria-selected="false">Missing</a>
</nav>
<div class="tab-content min-vh-50" id="nav-tabContent">
<div class="tab-content min-vh-25 pb-5" id="nav-tabContent">
<div class="tab-pane fade show active" id="nav-solves" role="tabpanel" aria-labelledby="nav-solves-tab">
<h3 class="text-center pt-5 d-block">Solves</h3>
<div class="row">
@@ -419,6 +421,13 @@
</div>
</div>
</div>
<div class="row min-vh-25 pt-5">
<div class="col-md-10 offset-md-1">
<div id="comment-box">
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -102,7 +102,7 @@
</a>
{% endif %}
{% if user.website %}
<a href="{{ user.website }}" target="_blank" class="badge badge-info">
<a href="{{ user.website }}" target="_blank" class="badge badge-info" rel="noopener">
<i class="btn-fa fas fa-external-link-alt" data-toggle="tooltip" data-placement="top"
title="{{ user.website }}" aria-hidden="true"></i>
</a>

View File

@@ -318,3 +318,7 @@ export function updateGraph(
let chart = echarts.init(document.querySelector(target));
chart.setOption(cfg.format(type, id, name, account_id, data));
}
export function disposeGraph(target) {
echarts.dispose(document.querySelector(target));
}

View File

@@ -63,8 +63,63 @@ const files = {
}
};
const comments = {
get_comments: extra_args => {
const CTFd = window.CTFd;
return CTFd.fetch("/api/v1/comments?" + $.param(extra_args), {
method: "GET",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
}
}).then(function(response) {
return response.json();
});
},
add_comment: (comment, type, extra_args, cb) => {
const CTFd = window.CTFd;
let body = {
content: comment,
type: type,
...extra_args
};
CTFd.fetch("/api/v1/comments", {
method: "POST",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify(body)
})
.then(function(response) {
return response.json();
})
.then(function(response) {
if (cb) {
cb(response);
}
});
},
delete_comment: comment_id => {
const CTFd = window.CTFd;
return CTFd.fetch(`/api/v1/comments/${comment_id}`, {
method: "DELETE",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
}
}).then(function(response) {
return response.json();
});
}
};
const helpers = {
files,
comments,
utils,
ezq
};

View File

@@ -24,6 +24,19 @@ function profileUpdate(event) {
const $form = $(this);
let params = $form.serializeJSON(true);
params.fields = [];
for (const property in params) {
if (property.match(/fields\[\d+\]/)) {
let field = {};
let id = parseInt(property.slice(7, -1));
field["field_id"] = id;
field["value"] = params[property];
params.fields.push(field);
delete params[property];
}
}
CTFd.api.patch_user_private({}, params).then(response => {
if (response.success) {
$("#results").html(success_template);

View File

@@ -16,13 +16,27 @@ $(() => {
});
}
var form = $("#team-info-form");
let form = $("#team-info-form");
form.submit(function(e) {
e.preventDefault();
$("#results").empty();
var params = $(this).serializeJSON();
var method = "PATCH";
var url = "/api/v1/teams/me";
let params = $(this).serializeJSON();
params.fields = [];
for (const property in params) {
if (property.match(/fields\[\d+\]/)) {
let field = {};
let id = parseInt(property.slice(7, -1));
field["field_id"] = id;
field["value"] = params[property];
params.fields.push(field);
delete params[property];
}
}
let method = "PATCH";
let url = "/api/v1/teams/me";
CTFd.fetch(url, {
method: method,
credentials: "same-origin",
@@ -42,12 +56,12 @@ $(() => {
' <button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">×</span></button>\n' +
"</div>";
Object.keys(object.errors).map(function(error) {
var i = form.find("input[name={0}]".format(error));
var input = $(i);
let i = form.find("input[name={0}]".format(error));
let input = $(i);
input.addClass("input-filled-invalid");
input.removeClass("input-filled-valid");
var error_msg = object.errors[error];
var alert = error_template.format(error_msg);
let error_msg = object.errors[error];
let alert = error_template.format(error_msg);
$("#results").append(alert);
});
}

View File

@@ -26,7 +26,7 @@ $.fn.serializeJSON = function(omit_nulls) {
if (x.value !== null && x.value !== "") {
params[x.name] = x.value;
} else {
let input = form.find(`:input[name=${x.name}]`);
let input = form.find(`:input[name='${x.name}']`);
if (input.data("initial") !== input.val()) {
params[x.name] = x.value;
}

File diff suppressed because one or more lines are too long

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