diff --git a/CHANGELOG.md b/CHANGELOG.md index d9eb40a8..78a5fcca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,80 @@ +# 3.2.0 / unreleased + +**General** + +- Add Team invites. + - Team invites are links containing a token that allow a user to join a team without knowing the team password + - Captains can generate invite tokens for their teams + - Admins can generate Team invite links as well +- Improved Team handling + - Prevent team joining while already on a team + - Return 403 instead of 200 for team join/create errors + - Allow team captains whose teams haven't done anything to disband their team +- Allow for uploading navbar logo, favicon, and index page banner during initial setup +- Fixed issue in teams mode where a user couldn't unlock a hint despite their team having enough points + - The fix for this is essentially to allow the user's points to go negative +- Imports have been made more stable + - This is primarily done by killing MySQL processes that are locking metadta + - This is a subpar approach but it seems to be the only solution to avoid a metadata lock in MySQL. This approach did not appear to be needed under Postgres or SQLite +- Update some migrations to first check if a table already exists. + +**API** + +- Addition of `POST /api/v1/teams/me/members` to generate invite tokens for teams +- Fixed an issue in `POST /api/v1/awards` where CTFd would 500 when a user could not be found by the provided `user_id` +- `POST /api/v1/unlocks` in teams mode now uses the team's score to determine if a user can purchase a hint + - Properly check for existing unlocks in teams mode in `POST /api/v1/unlocks` +- `/api/v1/notifications` and `/api/v1/notifications/[notification_id]` now have an html parameter which specifies the rendered content of the notification content + +**Themes** + +- Added syntax highlighting to challenge descriptions, pages, hints, notifications, comments, and markdown editors + - This is done with `highlight.js` which has been added to `package.json` +- Fix notifications to properly fix/support Markdown and HTML notifications + - Notifications SQL Model now has an html propery + - Notifications API schemas now has an html field +- Removed MomentJS (see https://momentjs.com/docs/#/-project-status/) in favor of dayjs + - dayjs is mostly API compatible with MomentJS. The only major changes were: + - dayjs always uses browser local time so you don't need to call `.local()` + - dayjs segments out some MomentJS functionality into plugins which need to be imported in before using those features +- Fixed issue in `challenge.html` where the current attempt count would have a typo +- Fixed issue in `challenge.html` where the max attempts for a challenge would not show if it was set to 1 +- Edit donut charts to have easier to read legends and labels +- Make data zoom bars thinner and more transparent + +**Plugins** + +- Don't run `db.create_all()` as much during plugin upgrade or during imports + - By avoiding this we can let alembic and migrations do more of the table creation work but this means that plugins specifically opt into `app.db.create_all()` and will not implicitly get it through `upgrade()`. + - This means plugins that run `upgrade()` without a migrations folder (no idea who would do this really) will need to upgrade their code. + +**Admin Panel** + +- Add Favicon uploading to the Admin Panel +- Move Logo uploading to the Theme tab in the Admin Panel +- The challenge left side bar tabs have been rewritten into VueJS components. + - This fixes a number of issues with the consistency of what data is deleted/edited in the challenge editor + - This also prevents having to refresh the page in most challenge editing situations +- Fixed a possible bug where the update available alert wouldn't go away on server restart +- Examples for regex flags are now provided +- Wrong submissions has been renamed to Incorrect Submissions +- Graphs in the Admin Statistics page will now scroll with mouse wheel to improve browsing large datasets + +**Deployment** + +- A restart policy set to `always` has been added to nginx in docker-compose +- Rename `requirements.txt` to `requirements.in` and generate `requirements.txt` using `pip-tools` under Python 3.6 +- `UPLOAD_PROVIDER` no longer has a default `filesystem` set in config.ini. Instead it is defaulted through `config.py` + +**Miscellaneous** + +- The `psycopg2` dependency in development.txt has been removed in favor of `psycopg2-binary` which was updated to 2.8.6 +- The `moto` dependency in development.txt has been updated to 1.3.16 +- Add `pip-tools` to `development.txt` +- Add `import_ctf` and `export_ctf` commands to `manage.py` and deprecate `import.py` and `export.py` +- Override the `MAIL_SERVER` config with the `TESTING_MAIL_SERVER` envvar during tests +- `ping` events in the notification event handler have been fixed to not send duplicates + # 3.1.1 / 2020-09-22 **General** diff --git a/CTFd/__init__.py b/CTFd/__init__.py index 1f057cbf..98046ad9 100644 --- a/CTFd/__init__.py +++ b/CTFd/__init__.py @@ -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.1.1" +__version__ = "3.2.0" __channel__ = "oss" diff --git a/CTFd/api/v1/challenges.py b/CTFd/api/v1/challenges.py index f24e38d0..1cc8eb92 100644 --- a/CTFd/api/v1/challenges.py +++ b/CTFd/api/v1/challenges.py @@ -750,3 +750,11 @@ class ChallengeFlags(Resource): return {"success": False, "errors": response.errors}, 400 return {"success": True, "data": response.data} + + +@challenges_namespace.route("//requirements") +class ChallengeRequirements(Resource): + @admins_only + def get(self, challenge_id): + challenge = Challenges.query.filter_by(id=challenge_id).first_or_404() + return {"success": True, "data": challenge.requirements} diff --git a/CTFd/api/v1/teams.py b/CTFd/api/v1/teams.py index 23cc8081..e343611f 100644 --- a/CTFd/api/v1/teams.py +++ b/CTFd/api/v1/teams.py @@ -16,6 +16,7 @@ from CTFd.models import Awards, Submissions, Teams, Unlocks, Users, db from CTFd.schemas.awards import AwardSchema from CTFd.schemas.submissions import SubmissionSchema from CTFd.schemas.teams import TeamSchema +from CTFd.utils import get_config from CTFd.utils.decorators import admins_only, authed_only, require_team from CTFd.utils.decorators.visibility import ( check_account_visibility, @@ -313,6 +314,16 @@ class TeamPrivate(Resource): responses={200: ("Success", "APISimpleSuccessResponse")}, ) def delete(self): + team_disbanding = get_config("team_disbanding", default="inactive_only") + if team_disbanding == "disabled": + return ( + { + "success": False, + "errors": {"": ["Team disbanding is currently disabled"]}, + }, + 403, + ) + team = get_current_team() if team.captain_id != session["id"]: return ( @@ -363,6 +374,26 @@ class TeamPrivate(Resource): return {"success": True} +@teams_namespace.route("/me/members") +class TeamPrivateMembers(Resource): + @authed_only + @require_team + def post(self): + team = get_current_team() + if team.captain_id != session["id"]: + return ( + { + "success": False, + "errors": {"": ["Only team captains can generate invite codes"]}, + }, + 403, + ) + + invite_code = team.get_invite_code() + response = {"code": invite_code} + return {"success": True, "data": response} + + @teams_namespace.route("//members") @teams_namespace.param("team_id", "Team ID") class TeamMembers(Resource): @@ -385,8 +416,14 @@ class TeamMembers(Resource): def post(self, team_id): team = Teams.query.filter_by(id=team_id).first_or_404() + # Generate an invite code if no user or body is specified + if len(request.data) == 0: + invite_code = team.get_invite_code() + response = {"code": invite_code} + return {"success": True, "data": response} + data = request.get_json() - user_id = data["user_id"] + user_id = data.get("user_id") user = Users.query.filter_by(id=user_id).first_or_404() if user.team_id is None: team.members.append(user) diff --git a/CTFd/api/v1/unlocks.py b/CTFd/api/v1/unlocks.py index 5a130f34..6cfb0a7b 100644 --- a/CTFd/api/v1/unlocks.py +++ b/CTFd/api/v1/unlocks.py @@ -11,6 +11,7 @@ from CTFd.constants import RawEnum from CTFd.models import Unlocks, db, get_class_by_tablename from CTFd.schemas.awards import AwardSchema from CTFd.schemas.unlocks import UnlockSchema +from CTFd.utils.config import is_teams_mode from CTFd.utils.decorators import ( admins_only, authed_only, @@ -18,7 +19,7 @@ from CTFd.utils.decorators import ( require_verified_emails, ) from CTFd.utils.helpers.models import build_model_filters -from CTFd.utils.user import get_current_user +from CTFd.utils.user import get_current_team, get_current_user unlocks_namespace = Namespace("unlocks", description="Endpoint to retrieve Unlocks") @@ -107,7 +108,14 @@ class UnlockList(Resource): Model = get_class_by_tablename(req["type"]) target = Model.query.filter_by(id=req["target"]).first_or_404() - if target.cost > user.score: + # We should use the team's score if in teams mode + if is_teams_mode(): + team = get_current_team() + score = team.score + else: + score = user.score + + if target.cost > score: return ( { "success": False, @@ -124,7 +132,13 @@ class UnlockList(Resource): if response.errors: return {"success": False, "errors": response.errors}, 400 - existing = Unlocks.query.filter_by(**req).first() + # Search for an existing unlock that matches the target and type + # And matches either the requesting user id or the requesting team id + existing = Unlocks.query.filter( + Unlocks.target == req["target"], + Unlocks.type == req["type"], + (Unlocks.user_id == req["user_id"]) | (Unlocks.team_id == req["team_id"]), + ).first() if existing: return ( { diff --git a/CTFd/constants/config.py b/CTFd/constants/config.py index ed9ed7fc..2f08bac9 100644 --- a/CTFd/constants/config.py +++ b/CTFd/constants/config.py @@ -49,6 +49,13 @@ class _ConfigsWrapper: def ctf_name(self): return get_config("ctf_name", default="CTFd") + @property + def ctf_small_icon(self): + icon = get_config("ctf_small_icon") + if icon: + return url_for("views.files", path=icon) + return url_for("views.themes", path="img/favicon.ico") + @property def theme_header(self): from CTFd.utils.helpers import markup diff --git a/CTFd/exceptions/__init__.py b/CTFd/exceptions/__init__.py index 55030cf0..9788f12f 100644 --- a/CTFd/exceptions/__init__.py +++ b/CTFd/exceptions/__init__.py @@ -4,3 +4,11 @@ class UserNotFoundException(Exception): class UserTokenExpiredException(Exception): pass + + +class TeamTokenExpiredException(Exception): + pass + + +class TeamTokenInvalidException(Exception): + pass diff --git a/CTFd/forms/config.py b/CTFd/forms/config.py index 6c8cb6e9..aee30b12 100644 --- a/CTFd/forms/config.py +++ b/CTFd/forms/config.py @@ -42,9 +42,18 @@ class AccountSettingsForm(BaseForm): choices=[("true", "Enabled"), ("false", "Disabled")], default="false", ) + team_disbanding = SelectField( + "Team Disbanding", + description="Control whether team capatins are allowed to disband their own teams", + choices=[ + ("inactive_only", "Enabled for Inactive Teams"), + ("disabled", "Disabled"), + ], + default="inactive_only", + ) name_changes = SelectField( "Name Changes", - description="Control whether users can change their names", + description="Control whether users and teams can change their names", choices=[("true", "Enabled"), ("false", "Disabled")], default="true", ) diff --git a/CTFd/forms/setup.py b/CTFd/forms/setup.py index f42168fc..d7f5ff24 100644 --- a/CTFd/forms/setup.py +++ b/CTFd/forms/setup.py @@ -1,4 +1,5 @@ from wtforms import ( + FileField, HiddenField, PasswordField, RadioField, @@ -45,6 +46,15 @@ class SetupForm(BaseForm): validators=[InputRequired()], ) + ctf_logo = FileField( + "Logo", + description="Logo to use for the website instead of a CTF name. Used as the home page button.", + ) + ctf_banner = FileField("Banner", description="Banner to use for the homepage.") + ctf_small_icon = FileField( + "Small Icon", + description="favicon used in user's browsers. Only PNGs accepted. Must be 32x32px.", + ) ctf_theme = SelectField( "Theme", description="CTFd Theme to use", diff --git a/CTFd/forms/teams.py b/CTFd/forms/teams.py index 80ccb27e..5b0abfa0 100644 --- a/CTFd/forms/teams.py +++ b/CTFd/forms/teams.py @@ -229,3 +229,11 @@ def TeamEditForm(*args, **kwargs): attach_custom_team_fields(_TeamEditForm) return _TeamEditForm(*args, **kwargs) + + +class TeamInviteForm(BaseForm): + link = URLField("Invite Link") + + +class TeamInviteJoinForm(BaseForm): + submit = SubmitField("Join") diff --git a/CTFd/models/__init__.py b/CTFd/models/__init__.py index 07949234..6da84c59 100644 --- a/CTFd/models/__init__.py +++ b/CTFd/models/__init__.py @@ -37,6 +37,13 @@ class Notifications(db.Model): user = db.relationship("Users", foreign_keys="Notifications.user_id", lazy="select") team = db.relationship("Teams", foreign_keys="Notifications.team_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)) + def __init__(self, *args, **kwargs): super(Notifications, self).__init__(**kwargs) @@ -521,6 +528,62 @@ class Teams(db.Model): entry for entry in self.field_entries if entry.field.public and entry.value ] + def get_invite_code(self): + from flask import current_app + from CTFd.utils.security.signing import serialize, hmac + + secret_key = current_app.config["SECRET_KEY"] + if isinstance(secret_key, str): + secret_key = secret_key.encode("utf-8") + + team_password_key = self.password.encode("utf-8") + verification_secret = secret_key + team_password_key + + invite_object = { + "id": self.id, + "v": hmac(str(self.id), secret=verification_secret), + } + code = serialize(data=invite_object, secret=secret_key) + return code + + @classmethod + def load_invite_code(cls, code): + from flask import current_app + from CTFd.utils.security.signing import ( + unserialize, + hmac, + BadTimeSignature, + BadSignature, + ) + from CTFd.exceptions import TeamTokenExpiredException, TeamTokenInvalidException + + secret_key = current_app.config["SECRET_KEY"] + if isinstance(secret_key, str): + secret_key = secret_key.encode("utf-8") + + # Unserialize the invite code + try: + # Links expire after 1 day + invite_object = unserialize(code, max_age=86400) + except BadTimeSignature: + raise TeamTokenExpiredException + except BadSignature: + raise TeamTokenInvalidException + + # Load the team by the ID in the invite + team_id = invite_object["id"] + team = cls.query.filter_by(id=team_id).first_or_404() + + # Create the team specific secret + team_password_key = team.password.encode("utf-8") + verification_secret = secret_key + team_password_key + + # Verify the team verficiation code + verified = hmac(str(team.id), secret=verification_secret) == invite_object["v"] + if verified is False: + raise TeamTokenInvalidException + return team + def get_solves(self, admin=False): from CTFd.utils import get_config diff --git a/CTFd/plugins/migrations.py b/CTFd/plugins/migrations.py index 7d5f85e7..77fed28f 100644 --- a/CTFd/plugins/migrations.py +++ b/CTFd/plugins/migrations.py @@ -40,8 +40,6 @@ def upgrade(plugin_name=None, revision=None): # Check if the plugin has migraitons migrations_path = os.path.join(current_app.plugins_dir, plugin_name, "migrations") if os.path.isdir(migrations_path) is False: - # Create any tables that the plugin may have - current_app.db.create_all() return engine = create_engine(database_url, poolclass=pool.NullPool) diff --git a/CTFd/schemas/notifications.py b/CTFd/schemas/notifications.py index 21a93765..8c3adf22 100644 --- a/CTFd/schemas/notifications.py +++ b/CTFd/schemas/notifications.py @@ -1,3 +1,5 @@ +from marshmallow import fields + from CTFd.models import Notifications, ma from CTFd.utils import string_types @@ -6,7 +8,10 @@ class NotificationSchema(ma.ModelSchema): class Meta: model = Notifications include_fk = True - dump_only = ("id", "date") + dump_only = ("id", "date", "html") + + # Used to force the schema to include the html property from the model + html = fields.Str() def __init__(self, view=None, *args, **kwargs): if view: diff --git a/CTFd/teams.py b/CTFd/teams.py index 0189e733..5678e2ff 100644 --- a/CTFd/teams.py +++ b/CTFd/teams.py @@ -1,6 +1,7 @@ -from flask import Blueprint, redirect, render_template, request, url_for +from flask import Blueprint, abort, redirect, render_template, request, url_for from CTFd.cache import clear_team_session, clear_user_session +from CTFd.exceptions import TeamTokenExpiredException, TeamTokenInvalidException from CTFd.models import TeamFieldEntries, TeamFields, Teams, db from CTFd.utils import config, get_config, validators from CTFd.utils.crypto import verify_password @@ -11,6 +12,7 @@ from CTFd.utils.decorators.visibility import ( check_score_visibility, ) from CTFd.utils.helpers import get_errors, get_infos +from CTFd.utils.humanize.words import pluralize from CTFd.utils.user import get_current_user, get_current_user_attrs teams = Blueprint("teams", __name__) @@ -50,6 +52,74 @@ def listing(): ) +@teams.route("/teams/invite", methods=["GET", "POST"]) +@authed_only +@require_team_mode +def invite(): + infos = get_infos() + errors = get_errors() + code = request.args.get("code") + + if code is None: + abort(404) + + user = get_current_user_attrs() + if user.team_id: + errors.append("You are already in a team. You cannot join another.") + + try: + team = Teams.load_invite_code(code) + except TeamTokenExpiredException: + abort(403, description="This invite URL has expired") + except TeamTokenInvalidException: + abort(403, description="This invite URL is invalid") + + team_size_limit = get_config("team_size", default=0) + + if request.method == "GET": + if team_size_limit: + infos.append( + "Teams are limited to {limit} member{plural}".format( + limit=team_size_limit, plural=pluralize(number=team_size_limit) + ) + ) + + return render_template( + "teams/invite.html", team=team, infos=infos, errors=errors + ) + + if request.method == "POST": + if errors: + return ( + render_template( + "teams/invite.html", team=team, infos=infos, errors=errors + ), + 403, + ) + + if team_size_limit and len(team.members) >= team_size_limit: + errors.append( + "{name} has already reached the team size limit of {limit}".format( + name=team.name, limit=team_size_limit + ) + ) + return ( + render_template( + "teams/invite.html", team=team, infos=infos, errors=errors + ), + 403, + ) + + user = get_current_user() + user.team_id = team.id + db.session.commit() + + clear_user_session(user_id=user.id) + clear_team_session(team_id=team.id) + + return redirect(url_for("challenges.listing")) + + @teams.route("/teams/join", methods=["GET", "POST"]) @authed_only @require_team_mode diff --git a/CTFd/themes/admin/assets/js/challenges/files.js b/CTFd/themes/admin/assets/js/challenges/files.js deleted file mode 100644 index a66ca657..00000000 --- a/CTFd/themes/admin/assets/js/challenges/files.js +++ /dev/null @@ -1,42 +0,0 @@ -import $ from "jquery"; -import CTFd from "core/CTFd"; -import { default as helpers } from "core/helpers"; -import { ezQuery } from "core/ezq"; - -export function addFile(event) { - event.preventDefault(); - let form = event.target; - let data = { - challenge: window.CHALLENGE_ID, - type: "challenge" - }; - helpers.files.upload(form, data, function(_response) { - setTimeout(function() { - window.location.reload(); - }, 700); - }); -} - -export function deleteFile(_event) { - const file_id = $(this).attr("file-id"); - const row = $(this) - .parent() - .parent(); - ezQuery({ - title: "Delete Files", - body: "Are you sure you want to delete this file?", - success: function() { - CTFd.fetch("/api/v1/files/" + file_id, { - method: "DELETE" - }) - .then(function(response) { - return response.json(); - }) - .then(function(response) { - if (response.success) { - row.remove(); - } - }); - } - }); -} diff --git a/CTFd/themes/admin/assets/js/challenges/hints.js b/CTFd/themes/admin/assets/js/challenges/hints.js deleted file mode 100644 index c4cd34bc..00000000 --- a/CTFd/themes/admin/assets/js/challenges/hints.js +++ /dev/null @@ -1,125 +0,0 @@ -import $ from "jquery"; -import CTFd from "core/CTFd"; -import { ezQuery } from "core/ezq"; - -export function showHintModal(event) { - event.preventDefault(); - $("#hint-edit-modal form") - .find("input, textarea") - .val("") - // Trigger a change on the textarea to get codemirror to clone changes in - .trigger("change"); - - $("#hint-edit-form textarea").each(function(i, e) { - if (e.hasOwnProperty("codemirror")) { - e.codemirror.refresh(); - } - }); - - $("#hint-edit-modal").modal(); -} - -export function showEditHintModal(event) { - event.preventDefault(); - const hint_id = $(this).attr("hint-id"); - - CTFd.fetch("/api/v1/hints/" + hint_id + "?preview=true", { - method: "GET", - credentials: "same-origin", - headers: { - Accept: "application/json", - "Content-Type": "application/json" - } - }) - .then(function(response) { - return response.json(); - }) - .then(function(response) { - if (response.success) { - $("#hint-edit-form input[name=content],textarea[name=content]") - .val(response.data.content) - // Trigger a change on the textarea to get codemirror to clone changes in - .trigger("change"); - - $("#hint-edit-modal") - .on("shown.bs.modal", function() { - $("#hint-edit-form textarea").each(function(i, e) { - if (e.hasOwnProperty("codemirror")) { - e.codemirror.refresh(); - } - }); - }) - .on("hide.bs.modal", function() { - $("#hint-edit-form textarea").each(function(i, e) { - $(e) - .val("") - .trigger("change"); - if (e.hasOwnProperty("codemirror")) { - e.codemirror.refresh(); - } - }); - }); - - $("#hint-edit-form input[name=cost]").val(response.data.cost); - $("#hint-edit-form input[name=id]").val(response.data.id); - - $("#hint-edit-modal").modal(); - } - }); -} - -export function deleteHint(event) { - event.preventDefault(); - const hint_id = $(this).attr("hint-id"); - const row = $(this) - .parent() - .parent(); - ezQuery({ - title: "Delete Hint", - body: "Are you sure you want to delete this hint?", - success: function() { - CTFd.fetch("/api/v1/hints/" + hint_id, { - method: "DELETE" - }) - .then(function(response) { - return response.json(); - }) - .then(function(data) { - if (data.success) { - row.remove(); - } - }); - } - }); -} - -export function editHint(event) { - event.preventDefault(); - const params = $(this).serializeJSON(true); - params["challenge"] = window.CHALLENGE_ID; - - let method = "POST"; - let url = "/api/v1/hints"; - if (params.id) { - method = "PATCH"; - url = "/api/v1/hints/" + params.id; - } - CTFd.fetch(url, { - method: method, - credentials: "same-origin", - headers: { - Accept: "application/json", - "Content-Type": "application/json" - }, - body: JSON.stringify(params) - }) - .then(function(response) { - return response.json(); - }) - .then(function(data) { - if (data.success) { - // TODO: Refresh hints on submit. - window.location.reload(); - } - }); -} diff --git a/CTFd/themes/admin/assets/js/challenges/requirements.js b/CTFd/themes/admin/assets/js/challenges/requirements.js deleted file mode 100644 index d5a6561b..00000000 --- a/CTFd/themes/admin/assets/js/challenges/requirements.js +++ /dev/null @@ -1,69 +0,0 @@ -import $ from "jquery"; -import CTFd from "core/CTFd"; - -export function addRequirement(event) { - event.preventDefault(); - const requirements = $("#prerequisite-add-form").serializeJSON(); - - // Shortcut if there's no prerequisite - if (!requirements["prerequisite"]) { - return; - } - - window.CHALLENGE_REQUIREMENTS.prerequisites.push( - parseInt(requirements["prerequisite"]) - ); - - const params = { - requirements: window.CHALLENGE_REQUIREMENTS - }; - - CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID, { - method: "PATCH", - credentials: "same-origin", - headers: { - Accept: "application/json", - "Content-Type": "application/json" - }, - body: JSON.stringify(params) - }) - .then(function(response) { - return response.json(); - }) - .then(function(data) { - if (data.success) { - // TODO: Make this refresh requirements - window.location.reload(); - } - }); -} - -export function deleteRequirement(_event) { - const challenge_id = $(this).attr("challenge-id"); - const row = $(this) - .parent() - .parent(); - - window.CHALLENGE_REQUIREMENTS.prerequisites.pop(challenge_id); - - const params = { - requirements: window.CHALLENGE_REQUIREMENTS - }; - CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID, { - method: "PATCH", - credentials: "same-origin", - headers: { - Accept: "application/json", - "Content-Type": "application/json" - }, - body: JSON.stringify(params) - }) - .then(function(response) { - return response.json(); - }) - .then(function(data) { - if (data.success) { - row.remove(); - } - }); -} diff --git a/CTFd/themes/admin/assets/js/components/comments/CommentBox.vue b/CTFd/themes/admin/assets/js/components/comments/CommentBox.vue index 649202ee..7ee7c1f2 100644 --- a/CTFd/themes/admin/assets/js/components/comments/CommentBox.vue +++ b/CTFd/themes/admin/assets/js/components/comments/CommentBox.vue @@ -120,7 +120,8 @@ diff --git a/CTFd/themes/admin/assets/js/components/files/ChallengeFilesList.vue b/CTFd/themes/admin/assets/js/components/files/ChallengeFilesList.vue new file mode 100644 index 00000000..1e0508d1 --- /dev/null +++ b/CTFd/themes/admin/assets/js/components/files/ChallengeFilesList.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/CTFd/themes/admin/assets/js/components/hints/HintCreationForm.vue b/CTFd/themes/admin/assets/js/components/hints/HintCreationForm.vue new file mode 100644 index 00000000..5807f355 --- /dev/null +++ b/CTFd/themes/admin/assets/js/components/hints/HintCreationForm.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/CTFd/themes/admin/assets/js/components/hints/HintEditForm.vue b/CTFd/themes/admin/assets/js/components/hints/HintEditForm.vue new file mode 100644 index 00000000..781f5ee9 --- /dev/null +++ b/CTFd/themes/admin/assets/js/components/hints/HintEditForm.vue @@ -0,0 +1,176 @@ + + + + + diff --git a/CTFd/themes/admin/assets/js/components/hints/HintsList.vue b/CTFd/themes/admin/assets/js/components/hints/HintsList.vue new file mode 100644 index 00000000..160b9c23 --- /dev/null +++ b/CTFd/themes/admin/assets/js/components/hints/HintsList.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/CTFd/themes/admin/assets/js/components/notifications/Notification.vue b/CTFd/themes/admin/assets/js/components/notifications/Notification.vue index 8f0bf566..a1a89656 100644 --- a/CTFd/themes/admin/assets/js/components/notifications/Notification.vue +++ b/CTFd/themes/admin/assets/js/components/notifications/Notification.vue @@ -14,7 +14,7 @@

{{ title }}

-

+

{{ this.localDate() }} @@ -25,19 +25,19 @@ diff --git a/CTFd/themes/admin/assets/js/components/requirements/Requirements.vue b/CTFd/themes/admin/assets/js/components/requirements/Requirements.vue new file mode 100644 index 00000000..19286365 --- /dev/null +++ b/CTFd/themes/admin/assets/js/components/requirements/Requirements.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/CTFd/themes/admin/assets/js/components/tags/TagsList.vue b/CTFd/themes/admin/assets/js/components/tags/TagsList.vue new file mode 100644 index 00000000..a5b6909d --- /dev/null +++ b/CTFd/themes/admin/assets/js/components/tags/TagsList.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/CTFd/themes/admin/assets/js/pages/challenge.js b/CTFd/themes/admin/assets/js/pages/challenge.js index 91840541..421fe615 100644 --- a/CTFd/themes/admin/assets/js/pages/challenge.js +++ b/CTFd/themes/admin/assets/js/pages/challenge.js @@ -6,19 +6,15 @@ import CTFd from "core/CTFd"; import { htmlEntities } from "core/utils"; import { ezQuery, ezAlert, ezToast } from "core/ezq"; import { default as helpers } from "core/helpers"; -import { addFile, deleteFile } from "../challenges/files"; -import { addTag, deleteTag } from "../challenges/tags"; -import { addRequirement, deleteRequirement } from "../challenges/requirements"; import { bindMarkdownEditors } from "../styles"; import Vue from "vue/dist/vue.esm.browser"; import CommentBox from "../components/comments/CommentBox.vue"; import FlagList from "../components/flags/FlagList.vue"; -import { - showHintModal, - editHint, - deleteHint, - showEditHintModal -} from "../challenges/hints"; +import Requirements from "../components/requirements/Requirements.vue"; +import TagsList from "../components/tags/TagsList.vue"; +import ChallengeFilesList from "../components/files/ChallengeFilesList.vue"; +import HintsList from "../components/hints/HintsList.vue"; +import hljs from "highlight.js"; const displayHint = data => { ezAlert({ @@ -299,6 +295,13 @@ $(() => { }); challenge.postRender(); + + $("#challenge-window") + .find("pre code") + .each(function(_idx) { + hljs.highlightBlock(this); + }); + window.location.replace( window.location.href.split("#")[0] + "#preview" ); @@ -401,20 +404,6 @@ $(() => { $("#challenge-create-options form").submit(handleChallengeOptions); - $("#tags-add-input").keyup(addTag); - $(".delete-tag").click(deleteTag); - - $("#prerequisite-add-form").submit(addRequirement); - $(".delete-requirement").click(deleteRequirement); - - $("#file-add-form").submit(addFile); - $(".delete-file").click(deleteFile); - - $("#hint-add-button").click(showHintModal); - $(".delete-hint").click(deleteHint); - $(".edit-hint").click(showEditHintModal); - $("#hint-edit-form").submit(editHint); - // Load FlagList component if (document.querySelector("#challenge-flags")) { const flagList = Vue.extend(FlagList); @@ -425,6 +414,46 @@ $(() => { }).$mount(vueContainer); } + // Load TagsList component + if (document.querySelector("#challenge-tags")) { + const tagList = Vue.extend(TagsList); + let vueContainer = document.createElement("div"); + document.querySelector("#challenge-tags").appendChild(vueContainer); + new tagList({ + propsData: { challenge_id: window.CHALLENGE_ID } + }).$mount(vueContainer); + } + + // Load Requirements component + if (document.querySelector("#prerequisite-add-form")) { + const reqsComponent = Vue.extend(Requirements); + let vueContainer = document.createElement("div"); + document.querySelector("#prerequisite-add-form").appendChild(vueContainer); + new reqsComponent({ + propsData: { challenge_id: window.CHALLENGE_ID } + }).$mount(vueContainer); + } + + // Load ChallengeFilesList component + if (document.querySelector("#challenge-files")) { + const challengeFilesList = Vue.extend(ChallengeFilesList); + let vueContainer = document.createElement("div"); + document.querySelector("#challenge-files").appendChild(vueContainer); + new challengeFilesList({ + propsData: { challenge_id: window.CHALLENGE_ID } + }).$mount(vueContainer); + } + + // Load HintsList component + if (document.querySelector("#challenge-hints")) { + const hintsList = Vue.extend(HintsList); + let vueContainer = document.createElement("div"); + document.querySelector("#challenge-hints").appendChild(vueContainer); + new hintsList({ + propsData: { challenge_id: window.CHALLENGE_ID } + }).$mount(vueContainer); + } + // Because this JS is shared by a few pages, // we should only insert the CommentBox if it's actually in use if (document.querySelector("#comment-box")) { diff --git a/CTFd/themes/admin/assets/js/pages/configs.js b/CTFd/themes/admin/assets/js/pages/configs.js index 13cf7220..b14ec954 100644 --- a/CTFd/themes/admin/assets/js/pages/configs.js +++ b/CTFd/themes/admin/assets/js/pages/configs.js @@ -1,8 +1,11 @@ import "./main"; import "core/utils"; import "bootstrap/js/dist/tab"; -import Moment from "moment-timezone"; -import moment from "moment-timezone"; +import dayjs from "dayjs"; +import advancedFormat from "dayjs/plugin/advancedFormat"; +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; +import timezones from "../timezones"; import CTFd from "core/CTFd"; import { default as helpers } from "core/helpers"; import $ from "jquery"; @@ -12,16 +15,20 @@ import "codemirror/mode/htmlmixed/htmlmixed.js"; import Vue from "vue/dist/vue.esm.browser"; import FieldList from "../components/configs/fields/FieldList.vue"; +dayjs.extend(advancedFormat); +dayjs.extend(utc); +dayjs.extend(timezone); + function loadTimestamp(place, timestamp) { if (typeof timestamp == "string") { - timestamp = parseInt(timestamp, 10); + timestamp = parseInt(timestamp, 10) * 1000; } - const m = Moment(timestamp * 1000); - $("#" + place + "-month").val(m.month() + 1); // Months are zero indexed (http://momentjs.com/docs/#/get-set/month/) - $("#" + place + "-day").val(m.date()); - $("#" + place + "-year").val(m.year()); - $("#" + place + "-hour").val(m.hour()); - $("#" + place + "-minute").val(m.minute()); + const d = dayjs(timestamp); + $("#" + place + "-month").val(d.month() + 1); // Months are zero indexed (https://day.js.org/docs/en/get-set/month) + $("#" + place + "-day").val(d.date()); + $("#" + place + "-year").val(d.year()); + $("#" + place + "-hour").val(d.hour()); + $("#" + place + "-minute").val(d.minute()); loadDateValues(place); } @@ -31,7 +38,7 @@ function loadDateValues(place) { const year = $("#" + place + "-year").val(); const hour = $("#" + place + "-hour").val(); const minute = $("#" + place + "-minute").val(); - const timezone = $("#" + place + "-timezone").val(); + const timezone_string = $("#" + place + "-timezone").val(); const utc = convertDateToMoment(month, day, year, hour, minute); if (isNaN(utc.unix())) { @@ -41,10 +48,10 @@ function loadDateValues(place) { } else { $("#" + place).val(utc.unix()); $("#" + place + "-local").val( - utc.local().format("dddd, MMMM Do YYYY, h:mm:ss a zz") + utc.format("dddd, MMMM Do YYYY, h:mm:ss a z (zzz)") ); $("#" + place + "-zonetime").val( - utc.tz(timezone).format("dddd, MMMM Do YYYY, h:mm:ss a zz") + utc.tz(timezone_string).format("dddd, MMMM Do YYYY, h:mm:ss a z (zzz)") ); } } @@ -82,7 +89,7 @@ function convertDateToMoment(month, day, year, hour, minute) { ":" + min_str + ":00"; - return Moment(date_string, Moment.ISO_8601); + return dayjs(date_string); } function updateConfigs(event) { @@ -163,6 +170,52 @@ function removeLogo() { }); } +function smallIconUpload(event) { + event.preventDefault(); + let form = event.target; + helpers.files.upload(form, {}, function(response) { + const f = response.data[0]; + const params = { + value: f.location + }; + CTFd.fetch("/api/v1/configs/ctf_small_icon", { + method: "PATCH", + body: JSON.stringify(params) + }) + .then(function(response) { + return response.json(); + }) + .then(function(response) { + if (response.success) { + window.location.reload(); + } else { + ezAlert({ + title: "Error!", + body: "Icon uploading failed!", + button: "Okay" + }); + } + }); + }); +} + +function removeSmallIcon() { + ezQuery({ + title: "Remove logo", + body: "Are you sure you'd like to remove the small site icon?", + success: function() { + const params = { + value: null + }; + CTFd.api + .patch_config({ configKey: "ctf_small_icon" }, params) + .then(_response => { + window.location.reload(); + }); + } + }); +} + function importConfig(event) { event.preventDefault(); let import_file = document.getElementById("import-file").files[0]; @@ -221,9 +274,9 @@ function exportConfig(event) { } function insertTimezones(target) { - let current = $("