mirror of
https://github.com/aljazceru/CTFd.git
synced 2025-12-17 05:54:19 +01:00
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:
3
.github/workflows/lint.yml
vendored
3
.github/workflows/lint.yml
vendored
@@ -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
|
||||
|
||||
9
.github/workflows/mysql.yml
vendored
9
.github/workflows/mysql.yml
vendored
@@ -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
|
||||
|
||||
3
.github/workflows/postgres.yml
vendored
3
.github/workflows/postgres.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/sqlite.yml
vendored
2
.github/workflows/sqlite.yml
vendored
@@ -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
|
||||
|
||||
36
CHANGELOG.md
36
CHANGELOG.md
@@ -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**
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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
159
CTFd/api/v1/comments.py
Normal file
@@ -0,0 +1,159 @@
|
||||
from typing import List
|
||||
|
||||
from flask import request, session
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from CTFd.api.v1.helpers.request import validate_args
|
||||
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
|
||||
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import (
|
||||
ChallengeComments,
|
||||
Comments,
|
||||
PageComments,
|
||||
TeamComments,
|
||||
UserComments,
|
||||
db,
|
||||
)
|
||||
from CTFd.schemas.comments import CommentSchema
|
||||
from CTFd.utils.decorators import admins_only
|
||||
from CTFd.utils.helpers.models import build_model_filters
|
||||
|
||||
comments_namespace = Namespace("comments", description="Endpoint to retrieve Comments")
|
||||
|
||||
|
||||
CommentModel = sqlalchemy_to_pydantic(Comments)
|
||||
|
||||
|
||||
class CommentDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: CommentModel
|
||||
|
||||
|
||||
class CommentListSuccessResponse(APIListSuccessResponse):
|
||||
data: List[CommentModel]
|
||||
|
||||
|
||||
comments_namespace.schema_model(
|
||||
"CommentDetailedSuccessResponse", CommentDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
comments_namespace.schema_model(
|
||||
"CommentListSuccessResponse", CommentListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
def get_comment_model(data):
|
||||
model = Comments
|
||||
if "challenge_id" in data:
|
||||
model = ChallengeComments
|
||||
elif "user_id" in data:
|
||||
model = UserComments
|
||||
elif "team_id" in data:
|
||||
model = TeamComments
|
||||
elif "page_id" in data:
|
||||
model = PageComments
|
||||
else:
|
||||
model = Comments
|
||||
return model
|
||||
|
||||
|
||||
@comments_namespace.route("")
|
||||
class CommentList(Resource):
|
||||
@admins_only
|
||||
@comments_namespace.doc(
|
||||
description="Endpoint to list Comment objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "CommentListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"challenge_id": (int, None),
|
||||
"user_id": (int, None),
|
||||
"team_id": (int, None),
|
||||
"page_id": (int, None),
|
||||
"q": (str, None),
|
||||
"field": (RawEnum("CommentFields", {"content": "content"}), None),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
CommentModel = get_comment_model(data=query_args)
|
||||
filters = build_model_filters(model=CommentModel, query=q, field=field)
|
||||
|
||||
comments = (
|
||||
CommentModel.query.filter_by(**query_args)
|
||||
.filter(*filters)
|
||||
.order_by(CommentModel.id.desc())
|
||||
.paginate(max_per_page=100)
|
||||
)
|
||||
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}
|
||||
@@ -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}
|
||||
|
||||
34
CTFd/auth.py
34
CTFd/auth.py
@@ -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(
|
||||
|
||||
7
CTFd/cache/__init__.py
vendored
7
CTFd/cache/__init__.py
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
14
CTFd/constants/static.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from CTFd.constants import JinjaEnum, RawEnum
|
||||
|
||||
|
||||
@JinjaEnum
|
||||
class CacheKeys(str, RawEnum):
|
||||
PUBLIC_SCOREBOARD_TABLE = "public_scoreboard_table"
|
||||
|
||||
|
||||
# Placeholder object. Not used, just imported to force initialization of any Enums here
|
||||
class _StaticsWrapper:
|
||||
pass
|
||||
|
||||
|
||||
Static = _StaticsWrapper()
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
14
CTFd/schemas/comments.py
Normal 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
38
CTFd/schemas/fields.py
Normal 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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
237
CTFd/themes/admin/assets/js/components/comments/CommentBox.vue
Normal file
237
CTFd/themes/admin/assets/js/components/comments/CommentBox.vue
Normal 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"
|
||||
>
|
||||
<<<
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-link p-0"
|
||||
@click="nextPage()"
|
||||
:disabled="next ? false : true"
|
||||
>
|
||||
>>>
|
||||
</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">×</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"
|
||||
>
|
||||
<<<
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-link p-0"
|
||||
@click="nextPage()"
|
||||
:disabled="next ? false : true"
|
||||
>
|
||||
>>>
|
||||
</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>
|
||||
200
CTFd/themes/admin/assets/js/components/configs/fields/Field.vue
Normal file
200
CTFd/themes/admin/assets/js/components/configs/fields/Field.vue
Normal 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">×</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>
|
||||
@@ -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>
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
277
CTFd/themes/admin/static/js/components.dev.js
Normal file
277
CTFd/themes/admin/static/js/components.dev.js
Normal file
File diff suppressed because one or more lines are too long
1
CTFd/themes/admin/static/js/components.min.js
vendored
Normal file
1
CTFd/themes/admin/static/js/components.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
217
CTFd/themes/admin/static/js/core.min.js
vendored
217
CTFd/themes/admin/static/js/core.min.js
vendored
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
15
CTFd/themes/admin/static/js/graphs.min.js
vendored
15
CTFd/themes/admin/static/js/graphs.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
CTFd/themes/admin/static/js/helpers.min.js
vendored
2
CTFd/themes/admin/static/js/helpers.min.js
vendored
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
22
CTFd/themes/admin/static/js/vendor.bundle.min.js
vendored
22
CTFd/themes/admin/static/js/vendor.bundle.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
41
CTFd/themes/admin/templates/configs/fields.html
Normal file
41
CTFd/themes/admin/templates/configs/fields.html
Normal 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>
|
||||
56
CTFd/themes/admin/templates/configs/legal.html
Normal file
56
CTFd/themes/admin/templates/configs/legal.html
Normal 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>
|
||||
@@ -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 %}
|
||||
|
||||
46
CTFd/themes/admin/templates/macros/forms.html
Normal file
46
CTFd/themes/admin/templates/macros/forms.html
Normal 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 %}
|
||||
@@ -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") }}
|
||||
|
||||
@@ -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") }}
|
||||
|
||||
23
CTFd/themes/admin/templates/modals/teams/statistics.html
Normal file
23
CTFd/themes/admin/templates/modals/teams/statistics.html
Normal 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>
|
||||
@@ -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") }}
|
||||
|
||||
@@ -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") }}
|
||||
|
||||
23
CTFd/themes/admin/templates/modals/users/statistics.html
Normal file
23
CTFd/themes/admin/templates/modals/users/statistics.html
Normal 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>
|
||||
@@ -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">×</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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">×</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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
146
CTFd/themes/core/static/js/core.min.js
vendored
146
CTFd/themes/core/static/js/core.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user