diff --git a/.gitignore b/.gitignore index 87512692..27f729ae 100644 --- a/.gitignore +++ b/.gitignore @@ -72,4 +72,4 @@ CTFd/uploads *.zip # JS -node_modules/ \ No newline at end of file +node_modules/ diff --git a/.travis.yml b/.travis.yml index 0da59c1e..ddc1cf6a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -28,6 +28,7 @@ before_install: - python3.6 -m pip install black==19.3b0 install: - pip install -r development.txt + - yarn install --non-interactive - yarn global add prettier@1.17.0 before_script: - psql -c 'create database ctfd;' -U postgres diff --git a/CHANGELOG.md b/CHANGELOG.md index 68fae2f0..673f5454 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,66 @@ +2.2.0 / 2019-12-22 +================== + +## Notice +2.2.0 focuses on updating the front end of CTFd to use more modern programming practices and changes some aspects of core CTFd design. If your current installation is using a custom theme or custom plugin with ***any*** kind of JavaScript, it is likely that you will need to upgrade that theme/plugin to be useable with v2.2.0. + +**General** +* Team size limits can now be enforced from the configuration panel +* Access tokens functionality for API usage +* Admins can now choose how to deliver their notifications + * Toast (new default) + * Alert + * Background + * Sound On / Sound Off +* There is now a notification counter showing how many unread notifications were received +* Setup has been redesigned to have multiple steps + * Added Description + * Added Start time and End time, + * Added MajorLeagueCyber integration + * Added Theme and color selection +* Fixes issue where updating dynamic challenges could change the value to an incorrect value +* Properly use a less restrictive regex to validate email addresses +* Bump Python dependencies to latest working versions +* Admins can now give awards to team members from the team's admin panel page + +**API** +* Team member removals (`DELETE /api/v1/teams/[team_id]/members`) from the admin panel will now delete the removed members's Submissions, Awards, Unlocks + +**Admin Panel** +* Admins can now user a color input box to specify a theme color which is injected as part of the CSS configuration. Theme developers can use this CSS value to change colors and styles accordingly. +* Challenge updates will now alert you if the challenge doesn't have a flag +* Challenge entry now allows you to upload files and enter simple flags from the initial challenge creation page + +**Themes** +* Significant JavaScript and CSS rewrite to use ES6, Webpack, yarn, and babel +* Theme asset specially generated URLs + * Static theme assets are now loaded with either .dev.extension or .min.extension depending on production or development (i.e. debug server) + * Static theme assets are also given a `d` GET parameter that changes per server start. Used to bust browser caches. +* Use `defer` for script tags to not block page rendering +* Only show the MajorLeagueCyber button if configured in configuration +* The admin panel now links to https://help.ctfd.io/ in the top right +* Create an `ezToast()` function to use [Bootstrap's toasts](https://getbootstrap.com/docs/4.3/components/toasts/) +* The user-facing navbar now features icons +* Awards shown on a user's profile can now have award icons +* The default MarkdownIt render created by CTFd will now open links in new tabs +* Country flags can now be shown on the user pages + +**Deployment** +* Switch `Dockerfile` from `python:2.7-alpine` to `python:3.7-alpine` +* Add `SERVER_SENT_EVENTS` config value to control whether Notifications are enabled +* Challenge ID is now recorded in the submission log + +**Plugins** +* Add an endpoint parameter to `register_plugin_assets_directory()` and `register_plugin_asset()` to control what endpoint Flask uses for the added route + +**Miscellaneous** +* `CTFd.utils.email.sendmail()` now allows the caller to specify subject as an argument + * The subject allows for injecting custom variable via the new `CTFd.utils.formatters.safe_format()` function +* Admin user information is now error checked during setup +* Added yarn to the toolchain and the yarn dev, yarn build, yarn verify, and yarn clean scripts +* Prevent old CTFd imports from being imported + + 2.1.5 / 2019-10-2 ================= diff --git a/CTFd/__init__.py b/CTFd/__init__.py index 00cdb61c..da56f945 100644 --- a/CTFd/__init__.py +++ b/CTFd/__init__.py @@ -21,14 +21,16 @@ from CTFd.utils.initialization import ( init_logs, init_events, ) +from CTFd.utils.crypto import sha256 from CTFd.plugins import init_plugins +import datetime # Hack to support Unicode in Python 2 properly if sys.version_info[0] < 3: reload(sys) # noqa: F821 sys.setdefaultencoding("utf-8") -__version__ = "2.1.5" +__version__ = "2.2.0" class CTFdRequest(Request): @@ -50,6 +52,12 @@ class CTFdFlask(Flask): self.jinja_environment = SandboxedBaseEnvironment self.session_interface = CachingSessionInterface(key_prefix="session") self.request_class = CTFdRequest + + # Store server start time + self.start_time = datetime.datetime.utcnow() + + # Create generally unique run identifier + self.run_id = sha256(str(self.start_time))[0:8] Flask.__init__(self, *args, **kwargs) def create_jinja_environment(self): diff --git a/CTFd/admin/statistics.py b/CTFd/admin/statistics.py index 79c867bc..831172a3 100644 --- a/CTFd/admin/statistics.py +++ b/CTFd/admin/statistics.py @@ -2,7 +2,7 @@ from flask import render_template from CTFd.utils.decorators import admins_only from CTFd.utils.updates import update_check from CTFd.utils.modes import get_model -from CTFd.models import db, Solves, Challenges, Fails, Tracking +from CTFd.models import db, Solves, Challenges, Fails, Tracking, Teams, Users from CTFd.admin import admin @@ -13,7 +13,8 @@ def statistics(): Model = get_model() - teams_registered = Model.query.count() + teams_registered = Teams.query.count() + users_registered = Users.query.count() wrong_count = ( Fails.query.join(Model, Fails.account_id == Model.id) @@ -65,6 +66,7 @@ def statistics(): return render_template( "admin/statistics.html", + user_count=users_registered, team_count=teams_registered, ip_count=ip_count, wrong_count=wrong_count, diff --git a/CTFd/api/__init__.py b/CTFd/api/__init__.py index 9eec5e8a..17256e3c 100644 --- a/CTFd/api/__init__.py +++ b/CTFd/api/__init__.py @@ -16,6 +16,7 @@ from CTFd.api.v1.config import configs_namespace from CTFd.api.v1.notifications import notifications_namespace from CTFd.api.v1.pages import pages_namespace from CTFd.api.v1.unlocks import unlocks_namespace +from CTFd.api.v1.tokens import tokens_namespace api = Blueprint("api", __name__, url_prefix="/api/v1") CTFd_API_v1 = Api(api, version="v1", doc=current_app.config.get("SWAGGER_UI")) @@ -35,3 +36,4 @@ CTFd_API_v1.add_namespace(notifications_namespace, "/notifications") CTFd_API_v1.add_namespace(configs_namespace, "/configs") CTFd_API_v1.add_namespace(pages_namespace, "/pages") CTFd_API_v1.add_namespace(unlocks_namespace, "/unlocks") +CTFd_API_v1.add_namespace(tokens_namespace, "/tokens") diff --git a/CTFd/api/v1/challenges.py b/CTFd/api/v1/challenges.py index ce7f09b0..5e7d1e23 100644 --- a/CTFd/api/v1/challenges.py +++ b/CTFd/api/v1/challenges.py @@ -260,13 +260,10 @@ class Challenge(Resource): Model = get_model() if scores_visible() is True and accounts_visible() is True: - solves = ( - Solves.query.join(Model, Solves.account_id == Model.id) - .filter( - Solves.challenge_id == chal.id, - Model.banned == False, - Model.hidden == False, - ) + solves = Solves.query.join(Model, Solves.account_id == Model.id).filter( + Solves.challenge_id == chal.id, + Model.banned == False, + Model.hidden == False, ) # Only show solves that happened before freeze time if configured @@ -391,8 +388,9 @@ class ChallengeAttempt(Resource): ) log( "submissions", - "[{date}] {name} submitted {submission} with kpm {kpm} [TOO FAST]", + "[{date}] {name} submitted {submission} on {challenge_id} with kpm {kpm} [TOO FAST]", submission=request_data["submission"].encode("utf-8"), + challenge_id=challenge_id, kpm=kpm, ) # Submitting too fast @@ -437,8 +435,9 @@ class ChallengeAttempt(Resource): log( "submissions", - "[{date}] {name} submitted {submission} with kpm {kpm} [CORRECT]", + "[{date}] {name} submitted {submission} on {challenge_id} with kpm {kpm} [CORRECT]", submission=request_data["submission"].encode("utf-8"), + challenge_id=challenge_id, kpm=kpm, ) return { @@ -454,8 +453,9 @@ class ChallengeAttempt(Resource): log( "submissions", - "[{date}] {name} submitted {submission} with kpm {kpm} [WRONG]", + "[{date}] {name} submitted {submission} on {challenge_id} with kpm {kpm} [WRONG]", submission=request_data["submission"].encode("utf-8"), + challenge_id=challenge_id, kpm=kpm, ) @@ -487,8 +487,9 @@ class ChallengeAttempt(Resource): else: log( "submissions", - "[{date}] {name} submitted {submission} with kpm {kpm} [ALREADY SOLVED]", + "[{date}] {name} submitted {submission} on {challenge_id} with kpm {kpm} [ALREADY SOLVED]", submission=request_data["submission"].encode("utf-8"), + challenge_id=challenge_id, kpm=kpm, ) return { diff --git a/CTFd/api/v1/notifications.py b/CTFd/api/v1/notifications.py index 5f8419e1..f3dffc1b 100644 --- a/CTFd/api/v1/notifications.py +++ b/CTFd/api/v1/notifications.py @@ -34,6 +34,13 @@ class NotificantionList(Resource): db.session.commit() response = schema.dump(result.data) + + # Grab additional settings + notif_type = req.get("type", "alert") + notif_sound = req.get("sound", True) + response.data["type"] = notif_type + response.data["sound"] = notif_sound + current_app.events_manager.publish(data=response.data, type="notification") return {"success": True, "data": response.data} diff --git a/CTFd/api/v1/teams.py b/CTFd/api/v1/teams.py index 2e471aa1..6e579dc4 100644 --- a/CTFd/api/v1/teams.py +++ b/CTFd/api/v1/teams.py @@ -1,6 +1,6 @@ from flask import session, request, abort from flask_restplus import Namespace, Resource -from CTFd.models import db, Users, Teams +from CTFd.models import db, Users, Teams, Submissions, Awards, Unlocks from CTFd.schemas.teams import TeamSchema from CTFd.schemas.submissions import SubmissionSchema from CTFd.schemas.awards import AwardSchema @@ -68,6 +68,8 @@ class TeamPublic(Resource): if response.errors: return {"success": False, "errors": response.errors}, 400 + response.data["place"] = team.place + response.data["score"] = team.score return {"success": True, "data": response.data} @admins_only @@ -118,6 +120,8 @@ class TeamPrivate(Resource): if response.errors: return {"success": False, "errors": response.errors}, 400 + response.data["place"] = team.place + response.data["score"] = team.score return {"success": True, "data": response.data} @authed_only @@ -206,6 +210,12 @@ class TeamMembers(Resource): if user.team_id == team.id: team.members.remove(user) + + # Remove information that links the user to the team + Submissions.query.filter_by(user_id=user.id).delete() + Awards.query.filter_by(user_id=user.id).delete() + Unlocks.query.filter_by(user_id=user.id).delete() + db.session.commit() else: return ( diff --git a/CTFd/api/v1/tokens.py b/CTFd/api/v1/tokens.py new file mode 100644 index 00000000..8e2b4b51 --- /dev/null +++ b/CTFd/api/v1/tokens.py @@ -0,0 +1,83 @@ +from flask import request, session +from flask_restplus import Namespace, Resource +from CTFd.models import db, Tokens +from CTFd.utils.user import get_current_user, is_admin +from CTFd.schemas.tokens import TokenSchema +from CTFd.utils.security.auth import generate_user_token +from CTFd.utils.decorators import require_verified_emails, authed_only +import datetime + +tokens_namespace = Namespace("tokens", description="Endpoint to retrieve Tokens") + + +@tokens_namespace.route("") +class TokenList(Resource): + @require_verified_emails + @authed_only + def get(self): + user = get_current_user() + tokens = Tokens.query.filter_by(user_id=user.id) + response = TokenSchema(view=["id", "type", "expiration"], many=True).dump( + tokens + ) + + if response.errors: + return {"success": False, "errors": response.errors}, 400 + + return {"success": True, "data": response.data} + + @require_verified_emails + @authed_only + def post(self): + req = request.get_json() + expiration = req.get("expiration") + if expiration: + expiration = datetime.datetime.strptime(expiration, "%Y-%m-%d") + + user = get_current_user() + token = generate_user_token(user, expiration=expiration) + + # Explicitly use admin view so that user's can see the value of their token + schema = TokenSchema(view="admin") + response = schema.dump(token) + + if response.errors: + return {"success": False, "errors": response.errors}, 400 + + return {"success": True, "data": response.data} + + +@tokens_namespace.route("/") +@tokens_namespace.param("token_id", "A Token ID") +class TokenDetail(Resource): + @require_verified_emails + @authed_only + def get(self, token_id): + if is_admin(): + token = Tokens.query.filter_by(id=token_id).first_or_404() + else: + token = Tokens.query.filter_by( + id=token_id, user_id=session["id"] + ).first_or_404() + + schema = TokenSchema(view=session.get("type", "user")) + response = schema.dump(token) + + if response.errors: + return {"success": False, "errors": response.errors}, 400 + + return {"success": True, "data": response.data} + + @require_verified_emails + @authed_only + def delete(self, token_id): + if is_admin(): + token = Tokens.query.filter_by(id=token_id).first_or_404() + else: + user = get_current_user() + token = Tokens.query.filter_by(id=token_id, user_id=user.id).first_or_404() + db.session.delete(token) + db.session.commit() + db.session.close() + + return {"success": True} diff --git a/CTFd/auth.py b/CTFd/auth.py index bc4eed9a..5ca6d52e 100644 --- a/CTFd/auth.py +++ b/CTFd/auth.py @@ -400,6 +400,15 @@ def oauth_redirect(): db.session.add(team) db.session.commit() + team_size_limit = get_config("team_size", default=0) + if team_size_limit and len(team.members) >= team_size_limit: + plural = "" if team_size_limit == 1 else "s" + size_error = "Teams are limited to {limit} member{plural}.".format( + limit=team_size_limit, plural=plural + ) + error_for(endpoint="auth.login", message=size_error) + return redirect(url_for("auth.login")) + team.members.append(user) db.session.commit() diff --git a/CTFd/config.py b/CTFd/config.py index 5370cdae..47b1edfc 100644 --- a/CTFd/config.py +++ b/CTFd/config.py @@ -228,6 +228,9 @@ class Config(object): APPLICATION_ROOT: Specifies what path CTFd is mounted under. It can be used to run CTFd in a subdirectory. Example: /ctfd + + SERVER_SENT_EVENTS: + Specifies whether or not to enable to server-sent events based Notifications system. """ REVERSE_PROXY = os.getenv("REVERSE_PROXY") or False TEMPLATES_AUTO_RELOAD = not os.getenv("TEMPLATES_AUTO_RELOAD") # Defaults True @@ -237,6 +240,7 @@ class Config(object): SWAGGER_UI = "/" if os.getenv("SWAGGER_UI") is not None else False # Defaults False UPDATE_CHECK = not os.getenv("UPDATE_CHECK") # Defaults True APPLICATION_ROOT = os.getenv("APPLICATION_ROOT") or "/" + SERVER_SENT_EVENTS = not os.getenv("SERVER_SENT_EVENTS") # Defaults True """ === OAUTH === diff --git a/CTFd/events/__init__.py b/CTFd/events/__init__.py index 7fad9199..09a22625 100644 --- a/CTFd/events/__init__.py +++ b/CTFd/events/__init__.py @@ -1,4 +1,5 @@ from flask import current_app, Blueprint, Response, stream_with_context +from CTFd.utils import get_app_config from CTFd.utils.decorators import authed_only, ratelimit events = Blueprint("events", __name__) @@ -13,4 +14,8 @@ def subscribe(): for event in current_app.events_manager.subscribe(): yield str(event) + enabled = get_app_config("SERVER_SENT_EVENTS") + if enabled is False: + return ("", 204) + return Response(gen(), mimetype="text/event-stream") diff --git a/CTFd/exceptions/__init__.py b/CTFd/exceptions/__init__.py new file mode 100644 index 00000000..55030cf0 --- /dev/null +++ b/CTFd/exceptions/__init__.py @@ -0,0 +1,6 @@ +class UserNotFoundException(Exception): + pass + + +class UserTokenExpiredException(Exception): + pass diff --git a/CTFd/models/__init__.py b/CTFd/models/__init__.py index 5dee907a..99c9ec0c 100644 --- a/CTFd/models/__init__.py +++ b/CTFd/models/__init__.py @@ -281,7 +281,12 @@ class Users(db.Model): @property def place(self): - return self.get_place(admin=False) + from CTFd.utils.config.visibility import scores_visible + + if scores_visible(): + return self.get_place(admin=False) + else: + return None def get_solves(self, admin=False): solves = Solves.query.filter_by(user_id=self.id) @@ -417,7 +422,12 @@ class Teams(db.Model): @property def place(self): - return self.get_place(admin=False) + from CTFd.utils.config.visibility import scores_visible + + if scores_visible(): + return self.get_place(admin=False) + else: + return None def get_solves(self, admin=False): member_ids = [member.id for member in self.members] @@ -631,6 +641,33 @@ class Configs(db.Model): super(Configs, self).__init__(**kwargs) +class Tokens(db.Model): + __tablename__ = "tokens" + id = db.Column(db.Integer, primary_key=True) + type = db.Column(db.String(32)) + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) + created = db.Column(db.DateTime, default=datetime.datetime.utcnow) + expiration = db.Column( + db.DateTime, + default=lambda: datetime.datetime.utcnow() + datetime.timedelta(days=30), + ) + value = db.Column(db.String(128), unique=True) + + user = db.relationship("Users", foreign_keys="Tokens.user_id", lazy="select") + + __mapper_args__ = {"polymorphic_on": type} + + def __init__(self, *args, **kwargs): + super(Tokens, self).__init__(**kwargs) + + def __repr__(self): + return "" % self.id + + +class UserTokens(Tokens): + __mapper_args__ = {"polymorphic_identity": "user"} + + @cache.memoize() def get_config(key): """ diff --git a/CTFd/plugins/__init__.py b/CTFd/plugins/__init__.py index 2ebb278d..dca1f8f9 100644 --- a/CTFd/plugins/__init__.py +++ b/CTFd/plugins/__init__.py @@ -18,7 +18,7 @@ from CTFd.utils.config.pages import get_pages Menu = namedtuple("Menu", ["title", "route"]) -def register_plugin_assets_directory(app, base_path, admins_only=False): +def register_plugin_assets_directory(app, base_path, admins_only=False, endpoint=None): """ Registers a directory to serve assets @@ -28,15 +28,17 @@ def register_plugin_assets_directory(app, base_path, admins_only=False): :return: """ base_path = base_path.strip("/") + if endpoint is None: + endpoint = base_path.replace("/", ".") def assets_handler(path): return send_from_directory(base_path, path) rule = "/" + base_path + "/" - app.add_url_rule(rule=rule, endpoint=base_path, view_func=assets_handler) + app.add_url_rule(rule=rule, endpoint=endpoint, view_func=assets_handler) -def register_plugin_asset(app, asset_path, admins_only=False): +def register_plugin_asset(app, asset_path, admins_only=False, endpoint=None): """ Registers an file path to be served by CTFd @@ -46,6 +48,8 @@ def register_plugin_asset(app, asset_path, admins_only=False): :return: """ asset_path = asset_path.strip("/") + if endpoint is None: + endpoint = asset_path.replace("/", ".") def asset_handler(): return send_file(asset_path) @@ -53,7 +57,7 @@ def register_plugin_asset(app, asset_path, admins_only=False): if admins_only: asset_handler = admins_only_wrapper(asset_handler) rule = "/" + asset_path - app.add_url_rule(rule=rule, endpoint=asset_path, view_func=asset_handler) + app.add_url_rule(rule=rule, endpoint=endpoint, view_func=asset_handler) def override_template(*args, **kwargs): diff --git a/CTFd/plugins/challenges/assets/create.html b/CTFd/plugins/challenges/assets/create.html index 79c72a91..edca7c58 100644 --- a/CTFd/plugins/challenges/assets/create.html +++ b/CTFd/plugins/challenges/assets/create.html @@ -1,7 +1,7 @@