diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..edadc674 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,17 @@ +module.exports = { + "env": { + "browser": true, + "es6": true + }, + "extends": "eslint:recommended", + "globals": { + "Atomics": "readonly", + "SharedArrayBuffer": "readonly" + }, + "parserOptions": { + "ecmaVersion": 2018, + "sourceType": "module" + }, + "rules": { + } +}; \ No newline at end of file diff --git a/.gitignore b/.gitignore index 27f729ae..59a35893 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,6 @@ CTFd/uploads # JS node_modules/ + +# Flask Profiler files +flask_profiler.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 74321606..5ac1700c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,53 @@ +2.4.0 / 2020-05-04 +================== + +**General** +* Cache user and team attributes and use those perform certain page operations intead of going to the database for data + * After modifying the user/team attributes you should call the appropriate cache clearing function (clear_user_session/clear_team_session) +* Cache user IPs for the last hour to avoid hitting the database on every authenticated page view + * Update the user IP's last seen value at least every hour or on every non-GET request +* Replace `flask_restplus` with `flask_restx` +* Remove `datafreeze`, `normality`, and `banal` dependencies in favor of in-repo solutions to exporting database + +**Admin Panel** +* Add bulk selection and deletion for Users, Teams, Scoreboard, Challenges, Submissions +* Make some Admin tables sortable by table headers +* Create a score distribution graph in the statistics page +* Make instance reset more granular to allow for choosing to reset Accounts, Submissions, Challenges, Pages, and/or Notificatoins +* Properly update challenge visibility after updating challenge +* Show total possible points in Statistics page +* Add searching for Users, Teams, Challenges, Submissions +* Move User IP addresses into a modal +* Move Team IP addresses into a modal +* Show User website in a user page button +* Show Team website in a team page button +* Make the Pages editor use proper HTML syntax highlighting +* Theme header and footer editors now use CodeMirror +* Make default CodeMirror font-size 12px +* Stop storing last action via location hash and switch to using sessionStorage + +**Themes** +* Make page selection a select and option instead of having a lot of page links +* Add the JSEnum class to create constants that can be accessed from webpack. Generate constants with `python manage.py build jsenums` +* Add the JinjaEnum class to inject constants into the Jinja environment to access from themes +* Update jQuery to 3.5.0 to resolve potential security issue +* Add some new CSS utilities (`.min-vh-*` and `.opacity-*`) +* Change some rows to have a minimum height so they don't render oddly without data +* Deprecate `.spinner-error` CSS class +* Deprecate accessing the type variable to check user role. Instead you should use `is_admin()` + +**Miscellaneous** +* Enable foreign key enforcement for SQLite. Only really matters for the debug server. +* Remove the duplicated `get_config` from `CTFd.models` +* Fix possible email sending issues in Python 3 by using `EmailMessage` +* Dont set User type in the user side session. Instead it should be set in the new user attributes +* Fix flask-profiler and bump dependency to 1.8.1 +* Switch to using the `Faker` library for `populate.py` instead of hardcoded data +* Add a `yarn lint` command to run eslint on JS files +* Always insert the current CTFd version at the end of the import process +* Fix issue where files could not be downloaded on Windows + + 2.3.3 / 2020-04-12 ================== diff --git a/CTFd/__init__.py b/CTFd/__init__.py index ae42dbc0..3837e7b3 100644 --- a/CTFd/__init__.py +++ b/CTFd/__init__.py @@ -31,7 +31,7 @@ if sys.version_info[0] < 3: reload(sys) # noqa: F821 sys.setdefaultencoding("utf-8") -__version__ = "2.3.3" +__version__ = "2.4.0" class CTFdRequest(Request): @@ -179,6 +179,19 @@ def create_app(config="CTFd.config.Config"): # Alembic sqlite support is lacking so we should just create_all anyway if url.drivername.startswith("sqlite"): + # Enable foreign keys for SQLite. This must be before the + # db.create_all call because tests use the in-memory SQLite + # database (each connection, including db creation, is a new db). + # https://docs.sqlalchemy.org/en/13/dialects/sqlite.html#foreign-key-support + from sqlalchemy.engine import Engine + from sqlalchemy import event + + @event.listens_for(Engine, "connect") + def set_sqlite_pragma(dbapi_connection, connection_record): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + db.create_all() stamp_latest_revision() else: diff --git a/CTFd/admin/__init__.py b/CTFd/admin/__init__.py index 4216614c..5be7241f 100644 --- a/CTFd/admin/__init__.py +++ b/CTFd/admin/__init__.py @@ -14,10 +14,13 @@ from flask import ( url_for, ) -from CTFd.cache import cache, clear_config +from CTFd.cache import cache, clear_config, clear_standings, clear_pages from CTFd.models import ( Awards, + Challenges, Configs, + Notifications, + Pages, Solves, Submissions, Teams, @@ -34,6 +37,7 @@ from CTFd.utils.exports import export_ctf as export_ctf_util from CTFd.utils.exports import import_ctf as import_ctf_util from CTFd.utils.helpers import get_errors from CTFd.utils.security.auth import logout_user +from CTFd.utils.uploads import delete_file from CTFd.utils.user import is_admin admin = Blueprint("admin", __name__) @@ -176,19 +180,60 @@ def config(): @admins_only def reset(): if request.method == "POST": - # Truncate Users, Teams, Submissions, Solves, Notifications, Awards, Unlocks, Tracking - Tracking.query.delete() - Solves.query.delete() - Submissions.query.delete() - Awards.query.delete() - Unlocks.query.delete() - Users.query.delete() - Teams.query.delete() - set_config("setup", False) + require_setup = False + logout = False + next_url = url_for("admin.statistics") + + data = request.form + + if data.get("pages"): + _pages = Pages.query.all() + for p in _pages: + for f in p.files: + delete_file(file_id=f.id) + + Pages.query.delete() + + if data.get("notifications"): + Notifications.query.delete() + + if data.get("challenges"): + _challenges = Challenges.query.all() + for c in _challenges: + for f in c.files: + delete_file(file_id=f.id) + Challenges.query.delete() + + if data.get("accounts"): + Users.query.delete() + Teams.query.delete() + require_setup = True + logout = True + + if data.get("submissions"): + Solves.query.delete() + Submissions.query.delete() + Awards.query.delete() + Unlocks.query.delete() + Tracking.query.delete() + + if require_setup: + set_config("setup", False) + cache.clear() + logout_user() + next_url = url_for("views.setup") + db.session.commit() - cache.clear() - logout_user() + + clear_pages() + clear_standings() + clear_config() + + if logout is True: + cache.clear() + logout_user() + db.session.close() - return redirect(url_for("views.setup")) + return redirect(next_url) return render_template("admin/reset.html") diff --git a/CTFd/admin/challenges.py b/CTFd/admin/challenges.py index b391b67f..63547621 100644 --- a/CTFd/admin/challenges.py +++ b/CTFd/admin/challenges.py @@ -2,7 +2,7 @@ import os import six from flask import current_app as app -from flask import render_template, render_template_string, url_for +from flask import render_template, render_template_string, request, url_for from CTFd.admin import admin from CTFd.models import Challenges, Flags, Solves @@ -14,8 +14,26 @@ from CTFd.utils.decorators import admins_only @admin.route("/admin/challenges") @admins_only def challenges_listing(): - challenges = Challenges.query.all() - return render_template("admin/challenges/challenges.html", challenges=challenges) + q = request.args.get("q") + field = request.args.get("field") + filters = [] + + if q: + # The field exists as an exposed column + if Challenges.__mapper__.has_property(field): + filters.append(getattr(Challenges, field).like("%{}%".format(q))) + + query = Challenges.query.filter(*filters).order_by(Challenges.id.asc()) + challenges = query.all() + total = query.count() + + return render_template( + "admin/challenges/challenges.html", + challenges=challenges, + total=total, + q=q, + field=field, + ) @admin.route("/admin/challenges/") diff --git a/CTFd/admin/statistics.py b/CTFd/admin/statistics.py index 6c54a213..ebbc2f72 100644 --- a/CTFd/admin/statistics.py +++ b/CTFd/admin/statistics.py @@ -31,6 +31,13 @@ def statistics(): challenge_count = Challenges.query.count() + total_points = ( + Challenges.query.with_entities(db.func.sum(Challenges.value).label("sum")) + .filter_by(state="visible") + .first() + .sum + ) or 0 + ip_count = Tracking.query.with_entities(Tracking.ip).distinct().count() solves_sub = ( @@ -73,6 +80,7 @@ def statistics(): wrong_count=wrong_count, solve_count=solve_count, challenge_count=challenge_count, + total_points=total_points, solve_data=solve_data, most_solved=most_solved, least_solved=least_solved, diff --git a/CTFd/admin/submissions.py b/CTFd/admin/submissions.py index f409a814..19e08e81 100644 --- a/CTFd/admin/submissions.py +++ b/CTFd/admin/submissions.py @@ -1,4 +1,4 @@ -from flask import render_template, request +from flask import render_template, request, url_for from CTFd.admin import admin from CTFd.models import Challenges, Submissions @@ -10,16 +10,21 @@ from CTFd.utils.modes import get_model @admin.route("/admin/submissions/") @admins_only def submissions_listing(submission_type): - filters = {} + filters_by = {} if submission_type: - filters["type"] = submission_type + filters_by["type"] = submission_type + filters = [] - curr_page = abs(int(request.args.get("page", 1, type=int))) - results_per_page = 50 - page_start = results_per_page * (curr_page - 1) - page_end = results_per_page * (curr_page - 1) + results_per_page - sub_count = Submissions.query.filter_by(**filters).count() - page_count = int(sub_count / results_per_page) + (sub_count % results_per_page > 0) + q = request.args.get("q") + field = request.args.get("field") + page = abs(request.args.get("page", 1, type=int)) + + if q: + submissions = [] + if Submissions.__mapper__.has_property( + field + ): # The field exists as an exposed column + filters.append(getattr(Submissions, field).like("%{}%".format(q))) Model = get_model() @@ -34,18 +39,27 @@ def submissions_listing(submission_type): Challenges.name.label("challenge_name"), Model.name.label("team_name"), ) - .filter_by(**filters) + .filter_by(**filters_by) + .filter(*filters) .join(Challenges) .join(Model) .order_by(Submissions.date.desc()) - .slice(page_start, page_end) - .all() + .paginate(page=page, per_page=50) ) + args = dict(request.args) + args.pop("page", 1) + return render_template( "admin/submissions.html", submissions=submissions, - page_count=page_count, - curr_page=curr_page, + prev_page=url_for( + request.endpoint, type=submission_type, page=submissions.prev_num, **args + ), + next_page=url_for( + request.endpoint, type=submission_type, page=submissions.next_num, **args + ), type=submission_type, + q=q, + field=field, ) diff --git a/CTFd/admin/teams.py b/CTFd/admin/teams.py index a8fb5058..7d9cf0e6 100644 --- a/CTFd/admin/teams.py +++ b/CTFd/admin/teams.py @@ -1,64 +1,40 @@ -from flask import render_template, request +from flask import render_template, request, url_for from sqlalchemy.sql import not_ from CTFd.admin import admin -from CTFd.models import Challenges, Teams, Tracking, db +from CTFd.models import Challenges, Teams, Tracking from CTFd.utils.decorators import admins_only -from CTFd.utils.helpers import get_errors @admin.route("/admin/teams") @admins_only def teams_listing(): - page = abs(request.args.get("page", 1, type=int)) q = request.args.get("q") + field = request.args.get("field") + page = abs(request.args.get("page", 1, type=int)) + filters = [] + if q: - field = request.args.get("field") - teams = [] - errors = get_errors() - if field == "id": - if q.isnumeric(): - teams = Teams.query.filter(Teams.id == q).order_by(Teams.id.asc()).all() - else: - teams = [] - errors.append("Your ID search term is not numeric") - elif field == "name": - teams = ( - Teams.query.filter(Teams.name.like("%{}%".format(q))) - .order_by(Teams.id.asc()) - .all() - ) - elif field == "email": - teams = ( - Teams.query.filter(Teams.email.like("%{}%".format(q))) - .order_by(Teams.id.asc()) - .all() - ) - elif field == "affiliation": - teams = ( - Teams.query.filter(Teams.affiliation.like("%{}%".format(q))) - .order_by(Teams.id.asc()) - .all() - ) - return render_template( - "admin/teams/teams.html", - teams=teams, - pages=0, - curr_page=None, - q=q, - field=field, - ) + # The field exists as an exposed column + if Teams.__mapper__.has_property(field): + filters.append(getattr(Teams, field).like("%{}%".format(q))) - page = abs(int(page)) - results_per_page = 50 - page_start = results_per_page * (page - 1) - page_end = results_per_page * (page - 1) + results_per_page + teams = ( + Teams.query.filter(*filters) + .order_by(Teams.id.asc()) + .paginate(page=page, per_page=50) + ) + + args = dict(request.args) + args.pop("page", 1) - teams = Teams.query.order_by(Teams.id.asc()).slice(page_start, page_end).all() - count = db.session.query(db.func.count(Teams.id)).first()[0] - pages = int(count / results_per_page) + (count % results_per_page > 0) return render_template( - "admin/teams/teams.html", teams=teams, pages=pages, curr_page=page + "admin/teams/teams.html", + teams=teams, + prev_page=url_for(request.endpoint, page=teams.prev_num, **args), + next_page=url_for(request.endpoint, page=teams.next_num, **args), + q=q, + field=field, ) diff --git a/CTFd/admin/users.py b/CTFd/admin/users.py index 758dc954..46f16c8a 100644 --- a/CTFd/admin/users.py +++ b/CTFd/admin/users.py @@ -1,75 +1,51 @@ -from flask import render_template, request +from flask import render_template, request, url_for from sqlalchemy.sql import not_ from CTFd.admin import admin -from CTFd.models import Challenges, Tracking, Users, db +from CTFd.models import Challenges, Tracking, Users from CTFd.utils import get_config from CTFd.utils.decorators import admins_only -from CTFd.utils.helpers import get_errors from CTFd.utils.modes import TEAMS_MODE @admin.route("/admin/users") @admins_only def users_listing(): - page = abs(request.args.get("page", 1, type=int)) q = request.args.get("q") - if q: - field = request.args.get("field") - users = [] - errors = get_errors() - if field == "id": - if q.isnumeric(): - users = Users.query.filter(Users.id == q).order_by(Users.id.asc()).all() - else: - users = [] - errors.append("Your ID search term is not numeric") - elif field == "name": - users = ( - Users.query.filter(Users.name.like("%{}%".format(q))) - .order_by(Users.id.asc()) - .all() - ) - elif field == "email": - users = ( - Users.query.filter(Users.email.like("%{}%".format(q))) - .order_by(Users.id.asc()) - .all() - ) - elif field == "affiliation": - users = ( - Users.query.filter(Users.affiliation.like("%{}%".format(q))) - .order_by(Users.id.asc()) - .all() - ) - elif field == "ip": - users = ( - Users.query.join(Tracking, Users.id == Tracking.user_id) - .filter(Tracking.ip.like("%{}%".format(q))) - .order_by(Users.id.asc()) - .all() - ) + field = request.args.get("field") + page = abs(request.args.get("page", 1, type=int)) + filters = [] + users = [] - return render_template( - "admin/users/users.html", - users=users, - pages=0, - curr_page=None, - q=q, - field=field, + if q: + # The field exists as an exposed column + if Users.__mapper__.has_property(field): + filters.append(getattr(Users, field).like("%{}%".format(q))) + + if q and field == "ip": + users = ( + Users.query.join(Tracking, Users.id == Tracking.user_id) + .filter(Tracking.ip.like("%{}%".format(q))) + .order_by(Users.id.asc()) + .paginate(page=page, per_page=50) + ) + else: + users = ( + Users.query.filter(*filters) + .order_by(Users.id.asc()) + .paginate(page=page, per_page=50) ) - page = abs(int(page)) - results_per_page = 50 - page_start = results_per_page * (page - 1) - page_end = results_per_page * (page - 1) + results_per_page - - users = Users.query.order_by(Users.id.asc()).slice(page_start, page_end).all() - count = db.session.query(db.func.count(Users.id)).first()[0] - pages = int(count / results_per_page) + (count % results_per_page > 0) + args = dict(request.args) + args.pop("page", 1) return render_template( - "admin/users/users.html", users=users, pages=pages, curr_page=page + "admin/users/users.html", + users=users, + prev_page=url_for(request.endpoint, page=users.prev_num, **args), + next_page=url_for(request.endpoint, page=users.next_num, **args), + q=q, + field=field, ) diff --git a/CTFd/api/__init__.py b/CTFd/api/__init__.py index efe51c35..58347503 100644 --- a/CTFd/api/__init__.py +++ b/CTFd/api/__init__.py @@ -1,5 +1,5 @@ from flask import Blueprint, current_app -from flask_restplus import Api +from flask_restx import Api from CTFd.api.v1.awards import awards_namespace from CTFd.api.v1.challenges import challenges_namespace diff --git a/CTFd/api/v1/awards.py b/CTFd/api/v1/awards.py index a77b36f5..14dd65f0 100644 --- a/CTFd/api/v1/awards.py +++ b/CTFd/api/v1/awards.py @@ -1,5 +1,5 @@ from flask import request -from flask_restplus import Namespace, Resource +from flask_restx import Namespace, Resource from CTFd.cache import clear_standings from CTFd.utils.config import is_teams_mode diff --git a/CTFd/api/v1/challenges.py b/CTFd/api/v1/challenges.py index a8f1fc95..9868476e 100644 --- a/CTFd/api/v1/challenges.py +++ b/CTFd/api/v1/challenges.py @@ -1,7 +1,7 @@ import datetime from flask import abort, request, url_for -from flask_restplus import Namespace, Resource +from flask_restx import Namespace, Resource from sqlalchemy.sql import and_ from CTFd.cache import clear_standings @@ -378,7 +378,7 @@ class ChallengeAttempt(Resource): log( "submissions", "[{date}] {name} submitted {submission} on {challenge_id} with kpm {kpm} [TOO FAST]", - submission=request_data["submission"].encode("utf-8"), + submission=request_data.get("submission", "").encode("utf-8"), challenge_id=challenge_id, kpm=kpm, ) @@ -425,7 +425,7 @@ class ChallengeAttempt(Resource): log( "submissions", "[{date}] {name} submitted {submission} on {challenge_id} with kpm {kpm} [CORRECT]", - submission=request_data["submission"].encode("utf-8"), + submission=request_data.get("submission", "").encode("utf-8"), challenge_id=challenge_id, kpm=kpm, ) @@ -443,7 +443,7 @@ class ChallengeAttempt(Resource): log( "submissions", "[{date}] {name} submitted {submission} on {challenge_id} with kpm {kpm} [WRONG]", - submission=request_data["submission"].encode("utf-8"), + submission=request_data.get("submission", "").encode("utf-8"), challenge_id=challenge_id, kpm=kpm, ) @@ -477,7 +477,7 @@ class ChallengeAttempt(Resource): log( "submissions", "[{date}] {name} submitted {submission} on {challenge_id} with kpm {kpm} [ALREADY SOLVED]", - submission=request_data["submission"].encode("utf-8"), + submission=request_data.get("submission", "").encode("utf-8"), challenge_id=challenge_id, kpm=kpm, ) diff --git a/CTFd/api/v1/config.py b/CTFd/api/v1/config.py index 5b9be704..ff7fd9dc 100644 --- a/CTFd/api/v1/config.py +++ b/CTFd/api/v1/config.py @@ -1,5 +1,5 @@ from flask import request -from flask_restplus import Namespace, Resource +from flask_restx import Namespace, Resource from CTFd.cache import clear_config, clear_standings from CTFd.models import Configs, db diff --git a/CTFd/api/v1/files.py b/CTFd/api/v1/files.py index 731f2673..394cfb09 100644 --- a/CTFd/api/v1/files.py +++ b/CTFd/api/v1/files.py @@ -1,5 +1,5 @@ from flask import request -from flask_restplus import Namespace, Resource +from flask_restx import Namespace, Resource from CTFd.models import Files, db from CTFd.schemas.files import FileSchema diff --git a/CTFd/api/v1/flags.py b/CTFd/api/v1/flags.py index 0819a2ee..08fe2785 100644 --- a/CTFd/api/v1/flags.py +++ b/CTFd/api/v1/flags.py @@ -1,5 +1,5 @@ from flask import request -from flask_restplus import Namespace, Resource +from flask_restx import Namespace, Resource from CTFd.models import Flags, db from CTFd.plugins.flags import FLAG_CLASSES, get_flag_class diff --git a/CTFd/api/v1/hints.py b/CTFd/api/v1/hints.py index 3bc62e2a..5acea7f0 100644 --- a/CTFd/api/v1/hints.py +++ b/CTFd/api/v1/hints.py @@ -1,5 +1,5 @@ from flask import request -from flask_restplus import Namespace, Resource +from flask_restx import Namespace, Resource from CTFd.models import Hints, HintUnlocks, db from CTFd.schemas.hints import HintSchema diff --git a/CTFd/api/v1/notifications.py b/CTFd/api/v1/notifications.py index 8dbb94bc..0cf63a74 100644 --- a/CTFd/api/v1/notifications.py +++ b/CTFd/api/v1/notifications.py @@ -1,5 +1,5 @@ from flask import current_app, request -from flask_restplus import Namespace, Resource +from flask_restx import Namespace, Resource from CTFd.models import Notifications, db from CTFd.schemas.notifications import NotificationSchema diff --git a/CTFd/api/v1/pages.py b/CTFd/api/v1/pages.py index dedec07e..b97bef81 100644 --- a/CTFd/api/v1/pages.py +++ b/CTFd/api/v1/pages.py @@ -1,5 +1,5 @@ from flask import request -from flask_restplus import Namespace, Resource +from flask_restx import Namespace, Resource from CTFd.cache import clear_pages from CTFd.models import Pages, db diff --git a/CTFd/api/v1/scoreboard.py b/CTFd/api/v1/scoreboard.py index 3fb6f529..5d44952a 100644 --- a/CTFd/api/v1/scoreboard.py +++ b/CTFd/api/v1/scoreboard.py @@ -1,4 +1,4 @@ -from flask_restplus import Namespace, Resource +from flask_restx import Namespace, Resource from CTFd.cache import cache, make_cache_key from CTFd.models import Awards, Solves, Teams diff --git a/CTFd/api/v1/statistics/__init__.py b/CTFd/api/v1/statistics/__init__.py index 935218cd..d7ac50cc 100644 --- a/CTFd/api/v1/statistics/__init__.py +++ b/CTFd/api/v1/statistics/__init__.py @@ -1,4 +1,4 @@ -from flask_restplus import Namespace +from flask_restx import Namespace statistics_namespace = Namespace( "statistics", description="Endpoint to retrieve Statistics" @@ -8,3 +8,4 @@ from CTFd.api.v1.statistics import challenges # noqa: F401 from CTFd.api.v1.statistics import submissions # noqa: F401 from CTFd.api.v1.statistics import teams # noqa: F401 from CTFd.api.v1.statistics import users # noqa: F401 +from CTFd.api.v1.statistics import scores # noqa: F401 diff --git a/CTFd/api/v1/statistics/challenges.py b/CTFd/api/v1/statistics/challenges.py index 8b6ac641..6a3bbfa7 100644 --- a/CTFd/api/v1/statistics/challenges.py +++ b/CTFd/api/v1/statistics/challenges.py @@ -1,4 +1,4 @@ -from flask_restplus import Resource +from flask_restx import Resource from sqlalchemy import func from sqlalchemy.sql import or_ diff --git a/CTFd/api/v1/statistics/scores.py b/CTFd/api/v1/statistics/scores.py new file mode 100644 index 00000000..f02b1a3a --- /dev/null +++ b/CTFd/api/v1/statistics/scores.py @@ -0,0 +1,43 @@ +from collections import defaultdict + +from flask_restx import Resource + +from CTFd.api.v1.statistics import statistics_namespace +from CTFd.models import db, Challenges +from CTFd.utils.decorators import admins_only +from CTFd.utils.scores import get_standings + + +@statistics_namespace.route("/scores/distribution") +class ScoresDistribution(Resource): + @admins_only + def get(self): + challenge_count = Challenges.query.count() or 1 + total_points = ( + Challenges.query.with_entities(db.func.sum(Challenges.value).label("sum")) + .filter_by(state="visible") + .first() + .sum + ) or 0 + # Convert Decimal() to int in some database backends for Python 2 + total_points = int(total_points) + + # Divide score by challenges to get brackets with explicit floor division + bracket_size = total_points // challenge_count + + # Get standings + standings = get_standings(admin=True) + + # Iterate over standings and increment the count for each bracket for each standing within that bracket + bottom, top = 0, bracket_size + count = 1 + brackets = defaultdict(lambda: 0) + for t in reversed(standings): + if ((t.score >= bottom) and (t.score <= top)) or t.score <= 0: + brackets[top] += 1 + else: + count += 1 + bottom, top = (bracket_size, (bracket_size * count)) + brackets[top] += 1 + + return {"success": True, "data": {"brackets": brackets}} diff --git a/CTFd/api/v1/statistics/submissions.py b/CTFd/api/v1/statistics/submissions.py index 97f76eaa..e1c62ba1 100644 --- a/CTFd/api/v1/statistics/submissions.py +++ b/CTFd/api/v1/statistics/submissions.py @@ -1,4 +1,4 @@ -from flask_restplus import Resource +from flask_restx import Resource from sqlalchemy import func from CTFd.api.v1.statistics import statistics_namespace diff --git a/CTFd/api/v1/statistics/teams.py b/CTFd/api/v1/statistics/teams.py index 8aa32788..4bfbbf29 100644 --- a/CTFd/api/v1/statistics/teams.py +++ b/CTFd/api/v1/statistics/teams.py @@ -1,4 +1,4 @@ -from flask_restplus import Resource +from flask_restx import Resource from CTFd.api.v1.statistics import statistics_namespace from CTFd.models import Teams diff --git a/CTFd/api/v1/statistics/users.py b/CTFd/api/v1/statistics/users.py index 0632bf61..881d9641 100644 --- a/CTFd/api/v1/statistics/users.py +++ b/CTFd/api/v1/statistics/users.py @@ -1,4 +1,4 @@ -from flask_restplus import Resource +from flask_restx import Resource from sqlalchemy import func from CTFd.api.v1.statistics import statistics_namespace diff --git a/CTFd/api/v1/submissions.py b/CTFd/api/v1/submissions.py index a88ac39a..7a2e5e68 100644 --- a/CTFd/api/v1/submissions.py +++ b/CTFd/api/v1/submissions.py @@ -1,5 +1,5 @@ from flask import request -from flask_restplus import Namespace, Resource +from flask_restx import Namespace, Resource from CTFd.cache import clear_standings from CTFd.models import Submissions, db diff --git a/CTFd/api/v1/tags.py b/CTFd/api/v1/tags.py index 4693921f..2134178a 100644 --- a/CTFd/api/v1/tags.py +++ b/CTFd/api/v1/tags.py @@ -1,5 +1,5 @@ from flask import request -from flask_restplus import Namespace, Resource +from flask_restx import Namespace, Resource from CTFd.models import Tags, db from CTFd.schemas.tags import TagSchema diff --git a/CTFd/api/v1/teams.py b/CTFd/api/v1/teams.py index 1213c6da..d387f81a 100644 --- a/CTFd/api/v1/teams.py +++ b/CTFd/api/v1/teams.py @@ -1,9 +1,9 @@ import copy from flask import abort, request, session -from flask_restplus import Namespace, Resource +from flask_restx import Namespace, Resource -from CTFd.cache import clear_standings +from CTFd.cache import clear_standings, clear_team_session, clear_user_session from CTFd.models import Awards, Submissions, Teams, Unlocks, Users, db from CTFd.schemas.awards import AwardSchema from CTFd.schemas.submissions import SubmissionSchema @@ -13,7 +13,7 @@ from CTFd.utils.decorators.visibility import ( check_account_visibility, check_score_visibility, ) -from CTFd.utils.user import get_current_team, is_admin +from CTFd.utils.user import get_current_team, get_current_user_type, is_admin teams_namespace = Namespace("teams", description="Endpoint to retrieve Teams") @@ -23,7 +23,8 @@ class TeamList(Resource): @check_account_visibility def get(self): teams = Teams.query.filter_by(hidden=False, banned=False) - view = copy.deepcopy(TeamSchema.views.get(session.get("type", "user"))) + user_type = get_current_user_type(fallback="user") + view = copy.deepcopy(TeamSchema.views.get(user_type)) view.remove("members") response = TeamSchema(view=view, many=True).dump(teams) @@ -35,7 +36,8 @@ class TeamList(Resource): @admins_only def post(self): req = request.get_json() - view = TeamSchema.views.get(session.get("type", "self")) + user_type = get_current_user_type() + view = TeamSchema.views.get(user_type) schema = TeamSchema(view=view) response = schema.load(req) @@ -63,7 +65,8 @@ class TeamPublic(Resource): if (team.banned or team.hidden) and is_admin() is False: abort(404) - view = TeamSchema.views.get(session.get("type", "user")) + user_type = get_current_user_type(fallback="user") + view = TeamSchema.views.get(user_type) schema = TeamSchema(view=view) response = schema.dump(team) @@ -88,25 +91,31 @@ class TeamPublic(Resource): response = schema.dump(response.data) db.session.commit() - db.session.close() + clear_team_session(team_id=team.id) clear_standings() + db.session.close() + return {"success": True, "data": response.data} @admins_only def delete(self, team_id): team = Teams.query.filter_by(id=team_id).first_or_404() + team_id = team.id for member in team.members: member.team_id = None + clear_user_session(user_id=member.id) db.session.delete(team) db.session.commit() - db.session.close() + clear_team_session(team_id=team_id) clear_standings() + db.session.close() + return {"success": True} @@ -147,7 +156,7 @@ class TeamPrivate(Resource): return {"success": False, "errors": response.errors}, 400 db.session.commit() - + clear_team_session(team_id=team.id) response = TeamSchema("self").dump(response.data) db.session.close() diff --git a/CTFd/api/v1/tokens.py b/CTFd/api/v1/tokens.py index a91f23e8..c8eaffa9 100644 --- a/CTFd/api/v1/tokens.py +++ b/CTFd/api/v1/tokens.py @@ -1,13 +1,13 @@ import datetime from flask import request, session -from flask_restplus import Namespace, Resource +from flask_restx import Namespace, Resource from CTFd.models import Tokens, db from CTFd.schemas.tokens import TokenSchema from CTFd.utils.decorators import authed_only, require_verified_emails from CTFd.utils.security.auth import generate_user_token -from CTFd.utils.user import get_current_user, is_admin +from CTFd.utils.user import get_current_user, get_current_user_type, is_admin tokens_namespace = Namespace("tokens", description="Endpoint to retrieve Tokens") @@ -62,7 +62,8 @@ class TokenDetail(Resource): id=token_id, user_id=session["id"] ).first_or_404() - schema = TokenSchema(view=session.get("type", "user")) + user_type = get_current_user_type(fallback="user") + schema = TokenSchema(view=user_type) response = schema.dump(token) if response.errors: diff --git a/CTFd/api/v1/unlocks.py b/CTFd/api/v1/unlocks.py index 61403b5a..b1499be1 100644 --- a/CTFd/api/v1/unlocks.py +++ b/CTFd/api/v1/unlocks.py @@ -1,5 +1,5 @@ from flask import request -from flask_restplus import Namespace, Resource +from flask_restx import Namespace, Resource from CTFd.cache import clear_standings from CTFd.models import Unlocks, db, get_class_by_tablename diff --git a/CTFd/api/v1/users.py b/CTFd/api/v1/users.py index 71436ba5..20e39980 100644 --- a/CTFd/api/v1/users.py +++ b/CTFd/api/v1/users.py @@ -1,7 +1,7 @@ -from flask import abort, request, session -from flask_restplus import Namespace, Resource +from flask import abort, request +from flask_restx import Namespace, Resource -from CTFd.cache import clear_standings +from CTFd.cache import clear_standings, clear_user_session from CTFd.models import ( Awards, Notifications, @@ -22,7 +22,7 @@ from CTFd.utils.decorators.visibility import ( check_score_visibility, ) from CTFd.utils.email import sendmail, user_created_notification -from CTFd.utils.user import get_current_user, is_admin +from CTFd.utils.user import get_current_user, get_current_user_type, is_admin users_namespace = Namespace("users", description="Endpoint to retrieve Users") @@ -80,7 +80,8 @@ class UserPublic(Resource): if (user.banned or user.hidden) and is_admin() is False: abort(404) - response = UserSchema(view=session.get("type", "user")).dump(user) + user_type = get_current_user_type(fallback="user") + response = UserSchema(view=user_type).dump(user) if response.errors: return {"success": False, "errors": response.errors}, 400 @@ -106,6 +107,7 @@ class UserPublic(Resource): db.session.close() + clear_user_session(user_id=user_id) clear_standings() return {"success": True, "data": response} @@ -122,6 +124,7 @@ class UserPublic(Resource): db.session.commit() db.session.close() + clear_user_session(user_id=user_id) clear_standings() return {"success": True} @@ -148,6 +151,7 @@ class UserPrivate(Resource): db.session.commit() + clear_user_session(user_id=user.id) response = schema.dump(response.data) db.session.close() diff --git a/CTFd/auth.py b/CTFd/auth.py index 93ca3bbe..4e778138 100644 --- a/CTFd/auth.py +++ b/CTFd/auth.py @@ -9,6 +9,7 @@ from itsdangerous.exc import BadSignature, BadTimeSignature, SignatureExpired from CTFd.models import Teams, Users, db from CTFd.utils import config, email, get_app_config, get_config from CTFd.utils import user as current_user +from CTFd.cache import clear_user_session, clear_team_session from CTFd.utils import validators from CTFd.utils.config import is_teams_mode from CTFd.utils.config.integrations import mlc_registration @@ -57,6 +58,7 @@ def confirm(data=None): name=user.name, ) db.session.commit() + clear_user_session(user_id=user.id) email.successful_registration_notification(user.email) db.session.close() if current_user.authed(): @@ -126,6 +128,7 @@ def reset_password(data=None): user.password = password db.session.commit() + clear_user_session(user_id=user.id) log( "logins", format="[{date}] {ip} - successful password reset for {name}", @@ -411,6 +414,7 @@ def oauth_redirect(): team = Teams(name=team_name, oauth_id=team_id, captain_id=user.id) db.session.add(team) db.session.commit() + clear_team_session(team_id=team.id) team_size_limit = get_config("team_size", default=0) if team_size_limit and len(team.members) >= team_size_limit: @@ -428,6 +432,7 @@ def oauth_redirect(): user.oauth_id = user_id user.verified = True db.session.commit() + clear_user_session(user_id=user.id) login_user(user) diff --git a/CTFd/cache/__init__.py b/CTFd/cache/__init__.py index 78a63b59..19d8047b 100644 --- a/CTFd/cache/__init__.py +++ b/CTFd/cache/__init__.py @@ -44,3 +44,21 @@ def clear_pages(): cache.delete_memoized(get_pages) cache.delete_memoized(get_page) + + +def clear_user_recent_ips(user_id): + from CTFd.utils.user import get_user_recent_ips + + cache.delete_memoized(get_user_recent_ips, user_id=user_id) + + +def clear_user_session(user_id): + from CTFd.utils.user import get_user_attrs + + cache.delete_memoized(get_user_attrs, user_id=user_id) + + +def clear_team_session(team_id): + from CTFd.utils.user import get_team_attrs + + cache.delete_memoized(get_team_attrs, team_id=team_id) diff --git a/CTFd/constants/__init__.py b/CTFd/constants/__init__.py new file mode 100644 index 00000000..8f58d2d7 --- /dev/null +++ b/CTFd/constants/__init__.py @@ -0,0 +1,63 @@ +from enum import Enum +from flask import current_app + +JS_ENUMS = {} + + +class RawEnum(Enum): + """ + This is a customized enum class which should be used with a mixin. + The mixin should define the types of each member. + + For example: + + class Colors(str, RawEnum): + RED = "red" + GREEN = "green" + BLUE = "blue" + """ + + def __str__(self): + return str(self._value_) + + @classmethod + def keys(cls): + return list(cls.__members__.keys()) + + @classmethod + def values(cls): + return list(cls.__members__.values()) + + @classmethod + def test(cls, value): + try: + return bool(cls(value)) + except ValueError: + return False + + +def JSEnum(cls): + """ + This is a decorator used to gather all Enums which should be shared with + the CTFd front end. The JS_Enums dictionary can be taken be a script and + compiled into a JavaScript file for use by frontend assets. JS_Enums + should not be passed directly into Jinja. A JinjaEnum is better for that. + """ + if cls.__name__ not in JS_ENUMS: + JS_ENUMS[cls.__name__] = dict(cls.__members__) + else: + raise KeyError("{} was already defined as a JSEnum".format(cls.__name__)) + return cls + + +def JinjaEnum(cls): + """ + This is a decorator used to inject the decorated Enum into Jinja globals + which allows you to access it from the front end. If you need to access + an Enum from JS, a better tool to use is the JSEnum decorator. + """ + if cls.__name__ not in current_app.jinja_env.globals: + current_app.jinja_env.globals[cls.__name__] = cls + else: + raise KeyError("{} was already defined as a JinjaEnum".format(cls.__name__)) + return cls diff --git a/CTFd/constants/teams.py b/CTFd/constants/teams.py new file mode 100644 index 00000000..d9de99d1 --- /dev/null +++ b/CTFd/constants/teams.py @@ -0,0 +1,20 @@ +from collections import namedtuple + +TeamAttrs = namedtuple( + "TeamAttrs", + [ + "id", + "oauth_id", + "name", + "email", + "secret", + "website", + "affiliation", + "country", + "bracket", + "hidden", + "banned", + "captain_id", + "created", + ], +) diff --git a/CTFd/constants/users.py b/CTFd/constants/users.py new file mode 100644 index 00000000..8fcf8150 --- /dev/null +++ b/CTFd/constants/users.py @@ -0,0 +1,22 @@ +from collections import namedtuple + +UserAttrs = namedtuple( + "UserAttrs", + [ + "id", + "oauth_id", + "name", + "email", + "type", + "secret", + "website", + "affiliation", + "country", + "bracket", + "hidden", + "banned", + "verified", + "team_id", + "created", + ], +) diff --git a/CTFd/models/__init__.py b/CTFd/models/__init__.py index 828ae674..777059bb 100644 --- a/CTFd/models/__init__.py +++ b/CTFd/models/__init__.py @@ -1,12 +1,10 @@ import datetime -import six from flask_marshmallow import Marshmallow from flask_sqlalchemy import SQLAlchemy from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import column_property, validates -from CTFd.cache import cache from CTFd.utils.crypto import hash_password from CTFd.utils.humanize.numbers import ordinalize @@ -142,6 +140,8 @@ class Awards(db.Model): @hybrid_property def account_id(self): + from CTFd.utils import get_config + user_mode = get_config("user_mode") if user_mode == "teams": return self.team_id @@ -259,6 +259,8 @@ class Users(db.Model): @hybrid_property def account_id(self): + from CTFd.utils import get_config + user_mode = get_config("user_mode") if user_mode == "teams": return self.team_id @@ -291,6 +293,8 @@ class Users(db.Model): return None def get_solves(self, admin=False): + from CTFd.utils import get_config + solves = Solves.query.filter_by(user_id=self.id) freeze = get_config("freeze") if freeze and admin is False: @@ -299,6 +303,8 @@ class Users(db.Model): return solves.all() def get_fails(self, admin=False): + from CTFd.utils import get_config + fails = Fails.query.filter_by(user_id=self.id) freeze = get_config("freeze") if freeze and admin is False: @@ -307,6 +313,8 @@ class Users(db.Model): return fails.all() def get_awards(self, admin=False): + from CTFd.utils import get_config + awards = Awards.query.filter_by(user_id=self.id) freeze = get_config("freeze") if freeze and admin is False: @@ -432,6 +440,8 @@ class Teams(db.Model): return None def get_solves(self, admin=False): + from CTFd.utils import get_config + member_ids = [member.id for member in self.members] solves = Solves.query.filter(Solves.user_id.in_(member_ids)).order_by( @@ -446,6 +456,8 @@ class Teams(db.Model): return solves.all() def get_fails(self, admin=False): + from CTFd.utils import get_config + member_ids = [member.id for member in self.members] fails = Fails.query.filter(Fails.user_id.in_(member_ids)).order_by( @@ -460,6 +472,8 @@ class Teams(db.Model): return fails.all() def get_awards(self, admin=False): + from CTFd.utils import get_config + member_ids = [member.id for member in self.members] awards = Awards.query.filter(Awards.user_id.in_(member_ids)).order_by( @@ -523,6 +537,8 @@ class Submissions(db.Model): @hybrid_property def account_id(self): + from CTFd.utils import get_config + user_mode = get_config("user_mode") if user_mode == "teams": return self.team_id @@ -531,6 +547,8 @@ class Submissions(db.Model): @hybrid_property def account(self): + from CTFd.utils import get_config + user_mode = get_config("user_mode") if user_mode == "teams": return self.team @@ -600,6 +618,8 @@ class Unlocks(db.Model): @hybrid_property def account_id(self): + from CTFd.utils import get_config + user_mode = get_config("user_mode") if user_mode == "teams": return self.team_id @@ -668,22 +688,3 @@ class Tokens(db.Model): class UserTokens(Tokens): __mapper_args__ = {"polymorphic_identity": "user"} - - -@cache.memoize() -def get_config(key): - """ - This should be a direct clone of its implementation in utils. It is used to avoid a circular import. - """ - config = Configs.query.filter_by(key=key).first() - if config and config.value: - value = config.value - if value and value.isdigit(): - return int(value) - elif value and isinstance(value, six.string_types): - if value.lower() == "true": - return True - elif value.lower() == "false": - return False - else: - return value diff --git a/CTFd/teams.py b/CTFd/teams.py index c26e41a4..5030e54a 100644 --- a/CTFd/teams.py +++ b/CTFd/teams.py @@ -1,5 +1,6 @@ from flask import Blueprint, redirect, render_template, request, url_for +from CTFd.cache import clear_user_session, clear_team_session from CTFd.models import Teams, db from CTFd.utils import config, get_config from CTFd.utils.crypto import verify_password @@ -63,7 +64,6 @@ def join(): passphrase = request.form.get("password", "").strip() team = Teams.query.filter_by(name=teamname).first() - user = get_current_user() if team and verify_password(passphrase, team.password): team_size_limit = get_config("team_size", default=0) @@ -77,6 +77,7 @@ def join(): "teams/join_team.html", infos=infos, errors=errors ) + user = get_current_user() user.team_id = team.id db.session.commit() @@ -84,6 +85,9 @@ def join(): team.captain_id = user.id db.session.commit() + clear_user_session(user_id=user.id) + clear_team_session(team_id=team.id) + return redirect(url_for("challenges.listing")) else: errors.append("That information is incorrect") @@ -130,6 +134,10 @@ def new(): 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")) diff --git a/CTFd/themes/admin/assets/css/admin.scss b/CTFd/themes/admin/assets/css/admin.scss index 910bdb91..a30273e0 100644 --- a/CTFd/themes/admin/assets/css/admin.scss +++ b/CTFd/themes/admin/assets/css/admin.scss @@ -66,3 +66,11 @@ tbody tr:hover { tr[data-href] { cursor: pointer; } + +.sort-col { + cursor: pointer; +} + +input[type="checkbox"] { + cursor: pointer; +} diff --git a/CTFd/themes/admin/assets/css/codemirror.scss b/CTFd/themes/admin/assets/css/codemirror.scss index 27511705..b5553ac4 100644 --- a/CTFd/themes/admin/assets/css/codemirror.scss +++ b/CTFd/themes/admin/assets/css/codemirror.scss @@ -1 +1,4 @@ @import "~codemirror/lib/codemirror.css"; +.CodeMirror { + font-size: 12px; +} diff --git a/CTFd/themes/admin/assets/js/challenges/challenge.js b/CTFd/themes/admin/assets/js/challenges/challenge.js index e72d347d..a9904221 100644 --- a/CTFd/themes/admin/assets/js/challenges/challenge.js +++ b/CTFd/themes/admin/assets/js/challenges/challenge.js @@ -220,14 +220,4 @@ $(() => { } }); }); - - if (window.location.hash) { - let hash = window.location.hash.replace("<>[]'\"", ""); - $('nav a[href="' + hash + '"]').tab("show"); - } - - $(".nav-tabs a").click(function(event) { - $(this).tab("show"); - window.location.hash = this.hash; - }); }); diff --git a/CTFd/themes/admin/assets/js/pages/challenge.js b/CTFd/themes/admin/assets/js/pages/challenge.js index 1a16c302..07733564 100644 --- a/CTFd/themes/admin/assets/js/pages/challenge.js +++ b/CTFd/themes/admin/assets/js/pages/challenge.js @@ -407,8 +407,23 @@ $(() => { .then(function(response) { return response.json(); }) - .then(function(data) { - if (data.success) { + .then(function(response) { + if (response.success) { + $(".challenge-state").text(response.data.state); + switch (response.data.state) { + case "visible": + $(".challenge-state") + .removeClass("badge-danger") + .addClass("badge-success"); + break; + case "hidden": + $(".challenge-state") + .removeClass("badge-success") + .addClass("badge-danger"); + break; + default: + break; + } ezToast({ title: "Success", body: "Your challenge has been updated!" @@ -432,16 +447,6 @@ $(() => { $("#challenge-create-options form").submit(handleChallengeOptions); - $(".nav-tabs a").click(function(e) { - $(this).tab("show"); - window.location.hash = this.hash; - }); - - if (window.location.hash) { - let hash = window.location.hash.replace("<>[]'\"", ""); - $('nav a[href="' + hash + '"]').tab("show"); - } - $("#tags-add-input").keyup(addTag); $(".delete-tag").click(deleteTag); diff --git a/CTFd/themes/admin/assets/js/pages/challenges.js b/CTFd/themes/admin/assets/js/pages/challenges.js index e69de29b..bb1ae451 100644 --- a/CTFd/themes/admin/assets/js/pages/challenges.js +++ b/CTFd/themes/admin/assets/js/pages/challenges.js @@ -0,0 +1,80 @@ +import "./main"; +import CTFd from "core/CTFd"; +import $ from "jquery"; +import { ezAlert, ezQuery } from "core/ezq"; + +function deleteSelectedChallenges(event) { + let challengeIDs = $("input[data-challenge-id]:checked").map(function() { + return $(this).data("challenge-id"); + }); + let target = challengeIDs.length === 1 ? "challenge" : "challenges"; + + ezQuery({ + title: "Delete Challenges", + body: `Are you sure you want to delete ${challengeIDs.length} ${target}?`, + success: function() { + const reqs = []; + for (var chalID of challengeIDs) { + reqs.push( + CTFd.fetch(`/api/v1/challenges/${chalID}`, { + method: "DELETE" + }) + ); + } + Promise.all(reqs).then(responses => { + window.location.reload(); + }); + } + }); +} + +function bulkEditChallenges(event) { + let challengeIDs = $("input[data-challenge-id]:checked").map(function() { + return $(this).data("challenge-id"); + }); + + ezAlert({ + title: "Edit Challenges", + body: $(` +
+
+ + +
+
+ + +
+
+ + +
+
+ `), + button: "Submit", + success: function() { + let data = $("#challenges-bulk-edit").serializeJSON(true); + const reqs = []; + for (var chalID of challengeIDs) { + reqs.push( + CTFd.fetch(`/api/v1/challenges/${chalID}`, { + method: "PATCH", + body: JSON.stringify(data) + }) + ); + } + Promise.all(reqs).then(responses => { + window.location.reload(); + }); + } + }); +} + +$(() => { + $("#challenges-delete-button").click(deleteSelectedChallenges); + $("#challenges-edit-button").click(bulkEditChallenges); +}); diff --git a/CTFd/themes/admin/assets/js/pages/configs.js b/CTFd/themes/admin/assets/js/pages/configs.js index 597f4ead..7b93dba1 100644 --- a/CTFd/themes/admin/assets/js/pages/configs.js +++ b/CTFd/themes/admin/assets/js/pages/configs.js @@ -7,6 +7,8 @@ import CTFd from "core/CTFd"; import { default as helpers } from "core/helpers"; import $ from "jquery"; import { ezQuery, ezProgressBar } from "core/ezq"; +import CodeMirror from "codemirror"; +import "codemirror/mode/htmlmixed/htmlmixed.js"; function loadTimestamp(place, timestamp) { if (typeof timestamp == "string") { @@ -218,10 +220,6 @@ function exportConfig(event) { window.location.href = $(this).attr("href"); } -function showTab(event) { - window.location.hash = this.hash; -} - function insertTimezones(target) { let current = $("