Merge remote-tracking branch 'origin/3.0.0-dev' into 1318-api-pagination

This commit is contained in:
Kevin Chung
2020-06-28 01:41:44 -04:00
58 changed files with 2733 additions and 1178 deletions

View File

@@ -6,9 +6,9 @@ If this is a feature request please describe the behavior that you'd like to see
**Environment**: **Environment**:
- CTFd Version/Commit: - CTFd Version/Commit:
- Operating System: - Operating System:
- Web Browser and Version: - Web Browser and Version:
**What happened?** **What happened?**
@@ -17,4 +17,3 @@ If this is a feature request please describe the behavior that you'd like to see
**How to reproduce your issue** **How to reproduce your issue**
**Any associated stack traces or error logs** **Any associated stack traces or error logs**

File diff suppressed because it is too large Load Diff

View File

@@ -2,19 +2,19 @@
#### **Did you find a bug?** #### **Did you find a bug?**
* **Do not open up a GitHub issue if the bug is a security vulnerability in CTFd**. Instead [email the details to us at support@ctfd.io](mailto:support@ctfd.io). - **Do not open up a GitHub issue if the bug is a security vulnerability in CTFd**. Instead [email the details to us at support@ctfd.io](mailto:support@ctfd.io).
* **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/CTFd/CTFd/issues). - **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/CTFd/CTFd/issues).
* If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/CTFd/CTFd/issues/new). Be sure to fill out the issue template with a **title and clear description**, and as much relevant information as possible (e.g. deployment setup, browser version, etc). - If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/CTFd/CTFd/issues/new). Be sure to fill out the issue template with a **title and clear description**, and as much relevant information as possible (e.g. deployment setup, browser version, etc).
#### **Did you write a patch that fixes a bug or implements a new feature?** #### **Did you write a patch that fixes a bug or implements a new feature?**
* Open a new pull request with the patch. - Open a new pull request with the patch.
* Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable. - Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable.
* Ensure all status checks pass. PR's with test failures will not be merged. PR's with insufficient coverage may be merged depending on the situation. - Ensure all status checks pass. PR's with test failures will not be merged. PR's with insufficient coverage may be merged depending on the situation.
#### **Did you fix whitespace, format code, or make a purely cosmetic patch?** #### **Did you fix whitespace, format code, or make a purely cosmetic patch?**

View File

@@ -27,7 +27,7 @@ def pages_preview():
data = request.form.to_dict() data = request.form.to_dict()
schema = PageSchema() schema = PageSchema()
page = schema.load(data) page = schema.load(data)
return render_template("page.html", content=build_html(page.data["content"])) return render_template("page.html", content=build_html(page.data.content))
@admin.route("/admin/pages/<int:page_id>") @admin.route("/admin/pages/<int:page_id>")

View File

@@ -9,6 +9,11 @@ from CTFd.api.v1.flags import flags_namespace
from CTFd.api.v1.hints import hints_namespace from CTFd.api.v1.hints import hints_namespace
from CTFd.api.v1.notifications import notifications_namespace from CTFd.api.v1.notifications import notifications_namespace
from CTFd.api.v1.pages import pages_namespace from CTFd.api.v1.pages import pages_namespace
from CTFd.api.v1.schemas import (
APIDetailedSuccessResponse,
APISimpleErrorResponse,
APISimpleSuccessResponse,
)
from CTFd.api.v1.scoreboard import scoreboard_namespace from CTFd.api.v1.scoreboard import scoreboard_namespace
from CTFd.api.v1.statistics import statistics_namespace from CTFd.api.v1.statistics import statistics_namespace
from CTFd.api.v1.submissions import submissions_namespace from CTFd.api.v1.submissions import submissions_namespace
@@ -21,6 +26,12 @@ from CTFd.api.v1.users import users_namespace
api = Blueprint("api", __name__, url_prefix="/api/v1") api = Blueprint("api", __name__, url_prefix="/api/v1")
CTFd_API_v1 = Api(api, version="v1", doc=current_app.config.get("SWAGGER_UI")) CTFd_API_v1 = Api(api, version="v1", doc=current_app.config.get("SWAGGER_UI"))
CTFd_API_v1.schema_model("APISimpleErrorResponse", APISimpleErrorResponse.schema())
CTFd_API_v1.schema_model(
"APIDetailedSuccessResponse", APIDetailedSuccessResponse.schema()
)
CTFd_API_v1.schema_model("APISimpleSuccessResponse", APISimpleSuccessResponse.schema())
CTFd_API_v1.add_namespace(challenges_namespace, "/challenges") CTFd_API_v1.add_namespace(challenges_namespace, "/challenges")
CTFd_API_v1.add_namespace(tags_namespace, "/tags") CTFd_API_v1.add_namespace(tags_namespace, "/tags")
CTFd_API_v1.add_namespace(awards_namespace, "/awards") CTFd_API_v1.add_namespace(awards_namespace, "/awards")

View File

@@ -1,6 +1,10 @@
from typing import List
from flask import request from flask import request
from flask_restx import Namespace, Resource from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.cache import clear_standings from CTFd.cache import clear_standings
from CTFd.models import Awards, Users, db from CTFd.models import Awards, Users, db
from CTFd.schemas.awards import AwardSchema from CTFd.schemas.awards import AwardSchema
@@ -9,10 +13,39 @@ from CTFd.utils.decorators import admins_only
awards_namespace = Namespace("awards", description="Endpoint to retrieve Awards") awards_namespace = Namespace("awards", description="Endpoint to retrieve Awards")
AwardModel = sqlalchemy_to_pydantic(Awards)
class AwardDetailedSuccessResponse(APIDetailedSuccessResponse):
data: AwardModel
class AwardListSuccessResponse(APIListSuccessResponse):
data: List[AwardModel]
awards_namespace.schema_model(
"AwardDetailedSuccessResponse", AwardDetailedSuccessResponse.apidoc()
)
awards_namespace.schema_model(
"AwardListSuccessResponse", AwardListSuccessResponse.apidoc()
)
@awards_namespace.route("") @awards_namespace.route("")
class AwardList(Resource): class AwardList(Resource):
@admins_only @admins_only
@awards_namespace.doc(
description="Endpoint to create an Award object",
responses={
200: ("Success", "AwardListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self): def post(self):
req = request.get_json() req = request.get_json()
@@ -57,6 +90,16 @@ class AwardList(Resource):
@awards_namespace.param("award_id", "An Award ID") @awards_namespace.param("award_id", "An Award ID")
class Award(Resource): class Award(Resource):
@admins_only @admins_only
@awards_namespace.doc(
description="Endpoint to get a specific Award object",
responses={
200: ("Success", "AwardDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self, award_id): def get(self, award_id):
award = Awards.query.filter_by(id=award_id).first_or_404() award = Awards.query.filter_by(id=award_id).first_or_404()
response = AwardSchema().dump(award) response = AwardSchema().dump(award)
@@ -66,6 +109,10 @@ class Award(Resource):
return {"success": True, "data": response.data} return {"success": True, "data": response.data}
@admins_only @admins_only
@awards_namespace.doc(
description="Endpoint to delete an Award object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, award_id): def delete(self, award_id):
award = Awards.query.filter_by(id=award_id).first_or_404() award = Awards.query.filter_by(id=award_id).first_or_404()
db.session.delete(award) db.session.delete(award)

View File

@@ -1,12 +1,25 @@
import datetime import datetime
from typing import List
from flask import abort, render_template, request, url_for from flask import abort, render_template, request, url_for
from flask_restx import Namespace, Resource from flask_restx import Namespace, Resource
from sqlalchemy.sql import and_ from sqlalchemy.sql import and_
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.cache import clear_standings from CTFd.cache import clear_standings
from CTFd.models import ChallengeFiles as ChallengeFilesModel from CTFd.models import ChallengeFiles as ChallengeFilesModel
from CTFd.models import Challenges, Fails, Flags, Hints, HintUnlocks, Solves, Tags, db from CTFd.models import (
Challenges,
Fails,
Flags,
Hints,
HintUnlocks,
Solves,
Submissions,
Tags,
db,
)
from CTFd.plugins.challenges import CHALLENGE_CLASSES, get_chal_class from CTFd.plugins.challenges import CHALLENGE_CLASSES, get_chal_class
from CTFd.schemas.flags import FlagSchema from CTFd.schemas.flags import FlagSchema
from CTFd.schemas.hints import HintSchema from CTFd.schemas.hints import HintSchema
@@ -37,12 +50,42 @@ challenges_namespace = Namespace(
"challenges", description="Endpoint to retrieve Challenges" "challenges", description="Endpoint to retrieve Challenges"
) )
ChallengeModel = sqlalchemy_to_pydantic(Challenges)
TransientChallengeModel = sqlalchemy_to_pydantic(Challenges, exclude=["id"])
class ChallengeDetailedSuccessResponse(APIDetailedSuccessResponse):
data: ChallengeModel
class ChallengeListSuccessResponse(APIListSuccessResponse):
data: List[ChallengeModel]
challenges_namespace.schema_model(
"ChallengeDetailedSuccessResponse", ChallengeDetailedSuccessResponse.apidoc()
)
challenges_namespace.schema_model(
"ChallengeListSuccessResponse", ChallengeListSuccessResponse.apidoc()
)
@challenges_namespace.route("") @challenges_namespace.route("")
class ChallengeList(Resource): class ChallengeList(Resource):
@check_challenge_visibility @check_challenge_visibility
@during_ctf_time_only @during_ctf_time_only
@require_verified_emails @require_verified_emails
@challenges_namespace.doc(
description="Endpoint to get Challenge objects in bulk",
responses={
200: ("Success", "ChallengeListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self): def get(self):
# This can return None (unauth) if visibility is set to public # This can return None (unauth) if visibility is set to public
user = get_current_user() user = get_current_user()
@@ -138,6 +181,16 @@ class ChallengeList(Resource):
} }
@admins_only @admins_only
@challenges_namespace.doc(
description="Endpoint to create a Challenge object",
responses={
200: ("Success", "ChallengeDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self): def post(self):
data = request.form or request.get_json() data = request.form or request.get_json()
challenge_type = data["type"] challenge_type = data["type"]
@@ -168,11 +221,20 @@ class ChallengeTypes(Resource):
@challenges_namespace.route("/<challenge_id>") @challenges_namespace.route("/<challenge_id>")
@challenges_namespace.param("challenge_id", "A Challenge ID")
class Challenge(Resource): class Challenge(Resource):
@check_challenge_visibility @check_challenge_visibility
@during_ctf_time_only @during_ctf_time_only
@require_verified_emails @require_verified_emails
@challenges_namespace.doc(
description="Endpoint to get a specific Challenge object",
responses={
200: ("Success", "ChallengeDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self, challenge_id): def get(self, challenge_id):
if is_admin(): if is_admin():
chal = Challenges.query.filter(Challenges.id == challenge_id).first_or_404() chal = Challenges.query.filter(Challenges.id == challenge_id).first_or_404()
@@ -289,6 +351,15 @@ class Challenge(Resource):
else: else:
response["solves"] = None response["solves"] = None
if authed():
# Get current attempts for the user
attempts = Submissions.query.filter_by(
account_id=user.account_id, challenge_id=challenge_id
).count()
else:
attempts = 0
response["attempts"] = attempts
response["files"] = files response["files"] = files
response["tags"] = tags response["tags"] = tags
response["hints"] = hints response["hints"] = hints
@@ -299,6 +370,8 @@ class Challenge(Resource):
files=files, files=files,
tags=tags, tags=tags,
hints=[Hints(**h) for h in hints], hints=[Hints(**h) for h in hints],
max_attempts=chal.max_attempts,
attempts=attempts,
challenge=chal, challenge=chal,
) )
@@ -306,6 +379,16 @@ class Challenge(Resource):
return {"success": True, "data": response} return {"success": True, "data": response}
@admins_only @admins_only
@challenges_namespace.doc(
description="Endpoint to edit a specific Challenge object",
responses={
200: ("Success", "ChallengeDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def patch(self, challenge_id): def patch(self, challenge_id):
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404() challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
challenge_class = get_chal_class(challenge.type) challenge_class = get_chal_class(challenge.type)
@@ -314,6 +397,10 @@ class Challenge(Resource):
return {"success": True, "data": response} return {"success": True, "data": response}
@admins_only @admins_only
@challenges_namespace.doc(
description="Endpoint to delete a specific Challenge object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, challenge_id): def delete(self, challenge_id):
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404() challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
chal_class = get_chal_class(challenge.type) chal_class = get_chal_class(challenge.type)
@@ -524,7 +611,6 @@ class ChallengeAttempt(Resource):
@challenges_namespace.route("/<challenge_id>/solves") @challenges_namespace.route("/<challenge_id>/solves")
@challenges_namespace.param("id", "A Challenge ID")
class ChallengeSolves(Resource): class ChallengeSolves(Resource):
@check_challenge_visibility @check_challenge_visibility
@check_score_visibility @check_score_visibility
@@ -572,7 +658,6 @@ class ChallengeSolves(Resource):
@challenges_namespace.route("/<challenge_id>/files") @challenges_namespace.route("/<challenge_id>/files")
@challenges_namespace.param("id", "A Challenge ID")
class ChallengeFiles(Resource): class ChallengeFiles(Resource):
@admins_only @admins_only
def get(self, challenge_id): def get(self, challenge_id):
@@ -588,7 +673,6 @@ class ChallengeFiles(Resource):
@challenges_namespace.route("/<challenge_id>/tags") @challenges_namespace.route("/<challenge_id>/tags")
@challenges_namespace.param("id", "A Challenge ID")
class ChallengeTags(Resource): class ChallengeTags(Resource):
@admins_only @admins_only
def get(self, challenge_id): def get(self, challenge_id):
@@ -604,7 +688,6 @@ class ChallengeTags(Resource):
@challenges_namespace.route("/<challenge_id>/hints") @challenges_namespace.route("/<challenge_id>/hints")
@challenges_namespace.param("id", "A Challenge ID")
class ChallengeHints(Resource): class ChallengeHints(Resource):
@admins_only @admins_only
def get(self, challenge_id): def get(self, challenge_id):
@@ -619,7 +702,6 @@ class ChallengeHints(Resource):
@challenges_namespace.route("/<challenge_id>/flags") @challenges_namespace.route("/<challenge_id>/flags")
@challenges_namespace.param("id", "A Challenge ID")
class ChallengeFlags(Resource): class ChallengeFlags(Resource):
@admins_only @admins_only
def get(self, challenge_id): def get(self, challenge_id):

View File

@@ -1,6 +1,10 @@
from typing import List
from flask import request from flask import request
from flask_restx import Namespace, Resource from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.cache import clear_config, clear_standings from CTFd.cache import clear_config, clear_standings
from CTFd.models import Configs, db from CTFd.models import Configs, db
from CTFd.schemas.config import ConfigSchema from CTFd.schemas.config import ConfigSchema
@@ -9,10 +13,39 @@ from CTFd.utils.decorators import admins_only
configs_namespace = Namespace("configs", description="Endpoint to retrieve Configs") configs_namespace = Namespace("configs", description="Endpoint to retrieve Configs")
ConfigModel = sqlalchemy_to_pydantic(Configs)
class ConfigDetailedSuccessResponse(APIDetailedSuccessResponse):
data: ConfigModel
class ConfigListSuccessResponse(APIListSuccessResponse):
data: List[ConfigModel]
configs_namespace.schema_model(
"ConfigDetailedSuccessResponse", ConfigDetailedSuccessResponse.apidoc()
)
configs_namespace.schema_model(
"ConfigListSuccessResponse", ConfigListSuccessResponse.apidoc()
)
@configs_namespace.route("") @configs_namespace.route("")
class ConfigList(Resource): class ConfigList(Resource):
@admins_only @admins_only
@configs_namespace.doc(
description="Endpoint to get Config objects in bulk",
responses={
200: ("Success", "ConfigListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self): def get(self):
configs = Configs.query.all() configs = Configs.query.all()
schema = ConfigSchema(many=True) schema = ConfigSchema(many=True)
@@ -23,6 +56,16 @@ class ConfigList(Resource):
return {"success": True, "data": response.data} return {"success": True, "data": response.data}
@admins_only @admins_only
@configs_namespace.doc(
description="Endpoint to get create a Config object",
responses={
200: ("Success", "ConfigDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self): def post(self):
req = request.get_json() req = request.get_json()
schema = ConfigSchema() schema = ConfigSchema()
@@ -43,6 +86,10 @@ class ConfigList(Resource):
return {"success": True, "data": response.data} return {"success": True, "data": response.data}
@admins_only @admins_only
@configs_namespace.doc(
description="Endpoint to get patch Config objects in bulk",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def patch(self): def patch(self):
req = request.get_json() req = request.get_json()
@@ -58,11 +105,13 @@ class ConfigList(Resource):
@configs_namespace.route("/<config_key>") @configs_namespace.route("/<config_key>")
class Config(Resource): class Config(Resource):
@admins_only @admins_only
# TODO: This returns weirdly structured data. It should more closely match ConfigDetailedSuccessResponse #1506
def get(self, config_key): def get(self, config_key):
return {"success": True, "data": get_config(config_key)} return {"success": True, "data": get_config(config_key)}
@admins_only @admins_only
# TODO: This returns weirdly structured data. It should more closely match ConfigDetailedSuccessResponse #1506
def patch(self, config_key): def patch(self, config_key):
config = Configs.query.filter_by(key=config_key).first() config = Configs.query.filter_by(key=config_key).first()
data = request.get_json() data = request.get_json()
@@ -89,6 +138,10 @@ class Config(Resource):
return {"success": True, "data": response.data} return {"success": True, "data": response.data}
@admins_only @admins_only
@configs_namespace.doc(
description="Endpoint to delete a Config object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, config_key): def delete(self, config_key):
config = Configs.query.filter_by(key=config_key).first_or_404() config = Configs.query.filter_by(key=config_key).first_or_404()

View File

@@ -1,6 +1,10 @@
from typing import List
from flask import request from flask import request
from flask_restx import Namespace, Resource from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.models import Files, db from CTFd.models import Files, db
from CTFd.schemas.files import FileSchema from CTFd.schemas.files import FileSchema
from CTFd.utils import uploads from CTFd.utils import uploads
@@ -8,10 +12,39 @@ from CTFd.utils.decorators import admins_only
files_namespace = Namespace("files", description="Endpoint to retrieve Files") files_namespace = Namespace("files", description="Endpoint to retrieve Files")
FileModel = sqlalchemy_to_pydantic(Files)
class FileDetailedSuccessResponse(APIDetailedSuccessResponse):
data: FileModel
class FileListSuccessResponse(APIListSuccessResponse):
data: List[FileModel]
files_namespace.schema_model(
"FileDetailedSuccessResponse", FileDetailedSuccessResponse.apidoc()
)
files_namespace.schema_model(
"FileListSuccessResponse", FileListSuccessResponse.apidoc()
)
@files_namespace.route("") @files_namespace.route("")
class FilesList(Resource): class FilesList(Resource):
@admins_only @admins_only
@files_namespace.doc(
description="Endpoint to get file objects in bulk",
responses={
200: ("Success", "FileListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self): def get(self):
file_type = request.args.get("type") file_type = request.args.get("type")
files = Files.query.filter_by(type=file_type).paginate(max_per_page=100) files = Files.query.filter_by(type=file_type).paginate(max_per_page=100)
@@ -37,6 +70,16 @@ class FilesList(Resource):
} }
@admins_only @admins_only
@files_namespace.doc(
description="Endpoint to get file objects in bulk",
responses={
200: ("Success", "FileDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self): def post(self):
files = request.files.getlist("file") files = request.files.getlist("file")
# challenge_id # challenge_id
@@ -60,6 +103,16 @@ class FilesList(Resource):
@files_namespace.route("/<file_id>") @files_namespace.route("/<file_id>")
class FilesDetail(Resource): class FilesDetail(Resource):
@admins_only @admins_only
@files_namespace.doc(
description="Endpoint to get a specific file object",
responses={
200: ("Success", "FileDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self, file_id): def get(self, file_id):
f = Files.query.filter_by(id=file_id).first_or_404() f = Files.query.filter_by(id=file_id).first_or_404()
schema = FileSchema() schema = FileSchema()
@@ -71,6 +124,10 @@ class FilesDetail(Resource):
return {"success": True, "data": response.data} return {"success": True, "data": response.data}
@admins_only @admins_only
@files_namespace.doc(
description="Endpoint to delete a file object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, file_id): def delete(self, file_id):
f = Files.query.filter_by(id=file_id).first_or_404() f = Files.query.filter_by(id=file_id).first_or_404()

View File

@@ -1,6 +1,10 @@
from typing import List
from flask import request from flask import request
from flask_restx import Namespace, Resource from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.models import Flags, db from CTFd.models import Flags, db
from CTFd.plugins.flags import FLAG_CLASSES, get_flag_class from CTFd.plugins.flags import FLAG_CLASSES, get_flag_class
from CTFd.schemas.flags import FlagSchema from CTFd.schemas.flags import FlagSchema
@@ -8,10 +12,39 @@ from CTFd.utils.decorators import admins_only
flags_namespace = Namespace("flags", description="Endpoint to retrieve Flags") flags_namespace = Namespace("flags", description="Endpoint to retrieve Flags")
FlagModel = sqlalchemy_to_pydantic(Flags)
class FlagDetailedSuccessResponse(APIDetailedSuccessResponse):
data: FlagModel
class FlagListSuccessResponse(APIListSuccessResponse):
data: List[FlagModel]
flags_namespace.schema_model(
"FlagDetailedSuccessResponse", FlagDetailedSuccessResponse.apidoc()
)
flags_namespace.schema_model(
"FlagListSuccessResponse", FlagListSuccessResponse.apidoc()
)
@flags_namespace.route("") @flags_namespace.route("")
class FlagList(Resource): class FlagList(Resource):
@admins_only @admins_only
@flags_namespace.doc(
description="Endpoint to list Flag objects in bulk",
responses={
200: ("Success", "FlagListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self): def get(self):
flags = Flags.query.paginate(max_per_page=100) flags = Flags.query.paginate(max_per_page=100)
schema = FlagSchema(many=True) schema = FlagSchema(many=True)
@@ -35,6 +68,16 @@ class FlagList(Resource):
} }
@admins_only @admins_only
@flags_namespace.doc(
description="Endpoint to create a Flag object",
responses={
200: ("Success", "FlagDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self): def post(self):
req = request.get_json() req = request.get_json()
schema = FlagSchema() schema = FlagSchema()
@@ -75,6 +118,16 @@ class FlagTypes(Resource):
@flags_namespace.route("/<flag_id>") @flags_namespace.route("/<flag_id>")
class Flag(Resource): class Flag(Resource):
@admins_only @admins_only
@flags_namespace.doc(
description="Endpoint to get a specific Flag object",
responses={
200: ("Success", "FlagDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self, flag_id): def get(self, flag_id):
flag = Flags.query.filter_by(id=flag_id).first_or_404() flag = Flags.query.filter_by(id=flag_id).first_or_404()
schema = FlagSchema() schema = FlagSchema()
@@ -88,6 +141,10 @@ class Flag(Resource):
return {"success": True, "data": response.data} return {"success": True, "data": response.data}
@admins_only @admins_only
@flags_namespace.doc(
description="Endpoint to delete a specific Flag object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, flag_id): def delete(self, flag_id):
flag = Flags.query.filter_by(id=flag_id).first_or_404() flag = Flags.query.filter_by(id=flag_id).first_or_404()
@@ -98,6 +155,16 @@ class Flag(Resource):
return {"success": True} return {"success": True}
@admins_only @admins_only
@flags_namespace.doc(
description="Endpoint to edit a specific Flag object",
responses={
200: ("Success", "FlagDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def patch(self, flag_id): def patch(self, flag_id):
flag = Flags.query.filter_by(id=flag_id).first_or_404() flag = Flags.query.filter_by(id=flag_id).first_or_404()
schema = FlagSchema() schema = FlagSchema()

View File

View File

@@ -0,0 +1,49 @@
from functools import wraps
from flask import request
from pydantic import create_model
ARG_LOCATIONS = {
"query": lambda: request.args,
"json": lambda: request.get_json(),
"form": lambda: request.form,
"headers": lambda: request.headers,
"cookies": lambda: request.cookies,
}
def validate_args(spec, location):
"""
A rough implementation of webargs using pydantic schemas. You can pass a
pydantic schema as spec or create it on the fly as follows:
@validate_args({"name": (str, None), "id": (int, None)}, location="query")
"""
if isinstance(spec, dict):
spec = create_model("", **spec)
schema = spec.schema()
props = schema.get("properties", {})
required = schema.get("required", [])
for k in props:
if k in required:
props[k]["required"] = True
props[k]["in"] = location
def decorator(func):
# Inject parameters information into the Flask-Restx apidoc attribute.
# Not really a good solution. See https://github.com/CTFd/CTFd/issues/1504
apidoc = getattr(func, "__apidoc__", {"params": {}})
apidoc["params"].update(props)
func.__apidoc__ = apidoc
@wraps(func)
def wrapper(*args, **kwargs):
data = ARG_LOCATIONS[location]()
loaded = spec(**data).dict(exclude_unset=True)
return func(*args, loaded, **kwargs)
return wrapper
return decorator

View File

@@ -0,0 +1,31 @@
from typing import Container, Type
from pydantic import BaseModel, create_model
from sqlalchemy.inspection import inspect
from sqlalchemy.orm.properties import ColumnProperty
def sqlalchemy_to_pydantic(
db_model: Type, *, exclude: Container[str] = []
) -> Type[BaseModel]:
"""
Mostly copied from https://github.com/tiangolo/pydantic-sqlalchemy
"""
mapper = inspect(db_model)
fields = {}
for attr in mapper.attrs:
if isinstance(attr, ColumnProperty):
if attr.columns:
column = attr.columns[0]
python_type = column.type.python_type
name = attr.key
if name in exclude:
continue
default = None
if column.default is None and not column.nullable:
default = ...
fields[name] = (python_type, default)
pydantic_model = create_model(
db_model.__name__, **fields # type: ignore
)
return pydantic_model

View File

@@ -1,6 +1,10 @@
from typing import List
from flask import request from flask import request
from flask_restx import Namespace, Resource from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.models import Hints, HintUnlocks, db from CTFd.models import Hints, HintUnlocks, db
from CTFd.schemas.hints import HintSchema from CTFd.schemas.hints import HintSchema
from CTFd.utils.decorators import admins_only, authed_only, during_ctf_time_only from CTFd.utils.decorators import admins_only, authed_only, during_ctf_time_only
@@ -8,10 +12,39 @@ from CTFd.utils.user import get_current_user, is_admin
hints_namespace = Namespace("hints", description="Endpoint to retrieve Hints") hints_namespace = Namespace("hints", description="Endpoint to retrieve Hints")
HintModel = sqlalchemy_to_pydantic(Hints)
class HintDetailedSuccessResponse(APIDetailedSuccessResponse):
data: HintModel
class HintListSuccessResponse(APIListSuccessResponse):
data: List[HintModel]
hints_namespace.schema_model(
"HintDetailedSuccessResponse", HintDetailedSuccessResponse.apidoc()
)
hints_namespace.schema_model(
"HintListSuccessResponse", HintListSuccessResponse.apidoc()
)
@hints_namespace.route("") @hints_namespace.route("")
class HintList(Resource): class HintList(Resource):
@admins_only @admins_only
@hints_namespace.doc(
description="Endpoint to list Hint objects in bulk",
responses={
200: ("Success", "HintListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self): def get(self):
hints = Hints.query.paginate(max_per_page=100) hints = Hints.query.paginate(max_per_page=100)
response = HintSchema(many=True).dump(hints.items) response = HintSchema(many=True).dump(hints.items)
@@ -35,6 +68,16 @@ class HintList(Resource):
} }
@admins_only @admins_only
@hints_namespace.doc(
description="Endpoint to create a Hint object",
responses={
200: ("Success", "HintDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self): def post(self):
req = request.get_json() req = request.get_json()
schema = HintSchema("admin") schema = HintSchema("admin")
@@ -55,6 +98,16 @@ class HintList(Resource):
class Hint(Resource): class Hint(Resource):
@during_ctf_time_only @during_ctf_time_only
@authed_only @authed_only
@hints_namespace.doc(
description="Endpoint to get a specific Hint object",
responses={
200: ("Success", "HintDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self, hint_id): def get(self, hint_id):
user = get_current_user() user = get_current_user()
hint = Hints.query.filter_by(id=hint_id).first_or_404() hint = Hints.query.filter_by(id=hint_id).first_or_404()
@@ -80,6 +133,16 @@ class Hint(Resource):
return {"success": True, "data": response.data} return {"success": True, "data": response.data}
@admins_only @admins_only
@hints_namespace.doc(
description="Endpoint to edit a specific Hint object",
responses={
200: ("Success", "HintDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def patch(self, hint_id): def patch(self, hint_id):
hint = Hints.query.filter_by(id=hint_id).first_or_404() hint = Hints.query.filter_by(id=hint_id).first_or_404()
req = request.get_json() req = request.get_json()
@@ -98,6 +161,10 @@ class Hint(Resource):
return {"success": True, "data": response.data} return {"success": True, "data": response.data}
@admins_only @admins_only
@hints_namespace.doc(
description="Endpoint to delete a specific Tag object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, hint_id): def delete(self, hint_id):
hint = Hints.query.filter_by(id=hint_id).first_or_404() hint = Hints.query.filter_by(id=hint_id).first_or_404()
db.session.delete(hint) db.session.delete(hint)

View File

@@ -1,6 +1,10 @@
from typing import List
from flask import current_app, request from flask import current_app, request
from flask_restx import Namespace, Resource from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.models import Notifications, db from CTFd.models import Notifications, db
from CTFd.schemas.notifications import NotificationSchema from CTFd.schemas.notifications import NotificationSchema
from CTFd.utils.decorators import admins_only from CTFd.utils.decorators import admins_only
@@ -9,9 +13,39 @@ notifications_namespace = Namespace(
"notifications", description="Endpoint to retrieve Notifications" "notifications", description="Endpoint to retrieve Notifications"
) )
NotificationModel = sqlalchemy_to_pydantic(Notifications)
TransientNotificationModel = sqlalchemy_to_pydantic(Notifications, exclude=["id"])
class NotificationDetailedSuccessResponse(APIDetailedSuccessResponse):
data: NotificationModel
class NotificationListSuccessResponse(APIListSuccessResponse):
data: List[NotificationModel]
notifications_namespace.schema_model(
"NotificationDetailedSuccessResponse", NotificationDetailedSuccessResponse.apidoc()
)
notifications_namespace.schema_model(
"NotificationListSuccessResponse", NotificationListSuccessResponse.apidoc()
)
@notifications_namespace.route("") @notifications_namespace.route("")
class NotificantionList(Resource): class NotificantionList(Resource):
@notifications_namespace.doc(
description="Endpoint to get notification objects in bulk",
responses={
200: ("Success", "NotificationListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self): def get(self):
notifications = Notifications.query.paginate(max_per_page=100) notifications = Notifications.query.paginate(max_per_page=100)
schema = NotificationSchema(many=True) schema = NotificationSchema(many=True)
@@ -34,6 +68,16 @@ class NotificantionList(Resource):
} }
@admins_only @admins_only
@notifications_namespace.doc(
description="Endpoint to create a notification object",
responses={
200: ("Success", "NotificationDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self): def post(self):
req = request.get_json() req = request.get_json()
@@ -62,6 +106,16 @@ class NotificantionList(Resource):
@notifications_namespace.route("/<notification_id>") @notifications_namespace.route("/<notification_id>")
@notifications_namespace.param("notification_id", "A Notification ID") @notifications_namespace.param("notification_id", "A Notification ID")
class Notification(Resource): class Notification(Resource):
@notifications_namespace.doc(
description="Endpoint to get a specific notification object",
responses={
200: ("Success", "NotificationDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self, notification_id): def get(self, notification_id):
notif = Notifications.query.filter_by(id=notification_id).first_or_404() notif = Notifications.query.filter_by(id=notification_id).first_or_404()
schema = NotificationSchema() schema = NotificationSchema()
@@ -72,6 +126,10 @@ class Notification(Resource):
return {"success": True, "data": response.data} return {"success": True, "data": response.data}
@admins_only @admins_only
@notifications_namespace.doc(
description="Endpoint to delete a notification object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, notification_id): def delete(self, notification_id):
notif = Notifications.query.filter_by(id=notification_id).first_or_404() notif = Notifications.query.filter_by(id=notification_id).first_or_404()
db.session.delete(notif) db.session.delete(notif)

View File

@@ -1,6 +1,11 @@
from typing import List
from flask import request from flask import request
from flask_restx import Namespace, Resource from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.request import validate_args
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.cache import clear_pages from CTFd.cache import clear_pages
from CTFd.models import Pages, db from CTFd.models import Pages, db
from CTFd.schemas.pages import PageSchema from CTFd.schemas.pages import PageSchema
@@ -9,11 +14,56 @@ from CTFd.utils.decorators import admins_only
pages_namespace = Namespace("pages", description="Endpoint to retrieve Pages") pages_namespace = Namespace("pages", description="Endpoint to retrieve Pages")
PageModel = sqlalchemy_to_pydantic(Pages)
TransientPageModel = sqlalchemy_to_pydantic(Pages, exclude=["id"])
class PageDetailedSuccessResponse(APIDetailedSuccessResponse):
data: PageModel
class PageListSuccessResponse(APIListSuccessResponse):
data: List[PageModel]
pages_namespace.schema_model(
"PageDetailedSuccessResponse", PageDetailedSuccessResponse.apidoc()
)
pages_namespace.schema_model(
"PageListSuccessResponse", PageListSuccessResponse.apidoc()
)
@pages_namespace.route("") @pages_namespace.route("")
@pages_namespace.doc(
responses={200: "Success", 400: "An error occured processing your data"}
)
class PageList(Resource): class PageList(Resource):
@admins_only @admins_only
def get(self): @pages_namespace.doc(
pages = Pages.query.paginate(max_per_page=100) description="Endpoint to get page objects in bulk",
responses={
200: ("Success", "PageListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
@validate_args(
{
"id": (int, None),
"title": (str, None),
"route": (str, None),
"draft": (bool, None),
"hidden": (bool, None),
"auth_required": (bool, None),
},
location="query",
)
def get(self, query):
pages = Pages.query.filter_by(**query).paginate(max_per_page=100)
schema = PageSchema(exclude=["content"], many=True) schema = PageSchema(exclude=["content"], many=True)
response = schema.dump(pages.items) response = schema.dump(pages.items)
if response.errors: if response.errors:
@@ -35,8 +85,19 @@ class PageList(Resource):
} }
@admins_only @admins_only
def post(self): @pages_namespace.doc(
req = request.get_json() description="Endpoint to create a page object",
responses={
200: ("Success", "PageDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
@validate_args(TransientPageModel, location="json")
def post(self, json_args):
req = json_args
schema = PageSchema() schema = PageSchema()
response = schema.load(req) response = schema.load(req)
@@ -55,8 +116,19 @@ class PageList(Resource):
@pages_namespace.route("/<page_id>") @pages_namespace.route("/<page_id>")
@pages_namespace.doc(
params={"page_id": "ID of a page object"},
responses={
200: ("Success", "PageDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
class PageDetail(Resource): class PageDetail(Resource):
@admins_only @admins_only
@pages_namespace.doc(description="Endpoint to read a page object")
def get(self, page_id): def get(self, page_id):
page = Pages.query.filter_by(id=page_id).first_or_404() page = Pages.query.filter_by(id=page_id).first_or_404()
schema = PageSchema() schema = PageSchema()
@@ -68,6 +140,7 @@ class PageDetail(Resource):
return {"success": True, "data": response.data} return {"success": True, "data": response.data}
@admins_only @admins_only
@pages_namespace.doc(description="Endpoint to edit a page object")
def patch(self, page_id): def patch(self, page_id):
page = Pages.query.filter_by(id=page_id).first_or_404() page = Pages.query.filter_by(id=page_id).first_or_404()
req = request.get_json() req = request.get_json()
@@ -88,6 +161,10 @@ class PageDetail(Resource):
return {"success": True, "data": response.data} return {"success": True, "data": response.data}
@admins_only @admins_only
@pages_namespace.doc(
description="Endpoint to delete a page object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, page_id): def delete(self, page_id):
page = Pages.query.filter_by(id=page_id).first_or_404() page = Pages.query.filter_by(id=page_id).first_or_404()
db.session.delete(page) db.session.delete(page)

View File

@@ -0,0 +1,105 @@
from typing import Any, Dict, List, Optional
from pydantic import BaseModel
class APISimpleSuccessResponse(BaseModel):
success: bool = True
class APIDetailedSuccessResponse(APISimpleSuccessResponse):
data: Optional[Any]
@classmethod
def apidoc(cls):
"""
Helper to inline references from the generated schema
"""
schema = cls.schema()
try:
key = schema["properties"]["data"]["$ref"]
ref = key.split("/").pop()
definition = schema["definitions"][ref]
schema["properties"]["data"] = definition
del schema["definitions"][ref]
if bool(schema["definitions"]) is False:
del schema["definitions"]
except KeyError:
pass
return schema
class APIListSuccessResponse(APIDetailedSuccessResponse):
data: Optional[List[Any]]
@classmethod
def apidoc(cls):
"""
Helper to inline references from the generated schema
"""
schema = cls.schema()
try:
key = schema["properties"]["data"]["items"]["$ref"]
ref = key.split("/").pop()
definition = schema["definitions"][ref]
schema["properties"]["data"]["items"] = definition
del schema["definitions"][ref]
if bool(schema["definitions"]) is False:
del schema["definitions"]
except KeyError:
pass
return schema
class PaginatedAPIListSuccessResponse(APIListSuccessResponse):
meta: Dict[str, Any]
@classmethod
def apidoc(cls):
"""
Helper to inline references from the generated schema
"""
schema = cls.schema()
schema["properties"]["meta"] = {
"title": "Meta",
"type": "object",
"properties": {
"pagination": {
"title": "Pagination",
"type": "object",
"properties": {
"page": {"title": "Page", "type": "integer"},
"next": {"title": "Next", "type": "integer"},
"prev": {"title": "Prev", "type": "integer"},
"pages": {"title": "Pages", "type": "integer"},
"per_page": {"title": "Per Page", "type": "integer"},
"total": {"title": "Total", "type": "integer"},
},
"required": ["page", "next", "prev", "pages", "per_page", "total"],
}
},
"required": ["pagination"],
}
try:
key = schema["properties"]["data"]["items"]["$ref"]
ref = key.split("/").pop()
definition = schema["definitions"][ref]
schema["properties"]["data"]["items"] = definition
del schema["definitions"][ref]
if bool(schema["definitions"]) is False:
del schema["definitions"]
except KeyError:
pass
return schema
class APISimpleErrorResponse(BaseModel):
success: bool = False
errors: Optional[List[str]]

View File

@@ -1,6 +1,14 @@
from typing import List
from flask import request from flask import request
from flask_restx import Namespace, Resource from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.request import validate_args
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import (
APIDetailedSuccessResponse,
PaginatedAPIListSuccessResponse,
)
from CTFd.cache import clear_standings from CTFd.cache import clear_standings
from CTFd.models import Submissions, db from CTFd.models import Submissions, db
from CTFd.schemas.submissions import SubmissionSchema from CTFd.schemas.submissions import SubmissionSchema
@@ -10,10 +18,40 @@ submissions_namespace = Namespace(
"submissions", description="Endpoint to retrieve Submission" "submissions", description="Endpoint to retrieve Submission"
) )
SubmissionModel = sqlalchemy_to_pydantic(Submissions)
TransientSubmissionModel = sqlalchemy_to_pydantic(Submissions, exclude=["id"])
class SubmissionDetailedSuccessResponse(APIDetailedSuccessResponse):
data: SubmissionModel
class SubmissionListSuccessResponse(PaginatedAPIListSuccessResponse):
data: List[SubmissionModel]
submissions_namespace.schema_model(
"SubmissionDetailedSuccessResponse", SubmissionDetailedSuccessResponse.apidoc()
)
submissions_namespace.schema_model(
"SubmissionListSuccessResponse", SubmissionListSuccessResponse.apidoc()
)
@submissions_namespace.route("") @submissions_namespace.route("")
class SubmissionsList(Resource): class SubmissionsList(Resource):
@admins_only @admins_only
@submissions_namespace.doc(
description="Endpoint to get submission objects in bulk",
responses={
200: ("Success", "SubmissionListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self): def get(self):
args = request.args.to_dict() args = request.args.to_dict()
schema = SubmissionSchema(many=True) schema = SubmissionSchema(many=True)
@@ -51,8 +89,19 @@ class SubmissionsList(Resource):
} }
@admins_only @admins_only
def post(self): @submissions_namespace.doc(
req = request.get_json() description="Endpoint to create a submission object. Users should interact with the attempt endpoint to submit flags.",
responses={
200: ("Success", "SubmissionListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
@validate_args(TransientSubmissionModel, location="json")
def post(self, json_args):
req = json_args
Model = Submissions.get_child(type=req.get("type")) Model = Submissions.get_child(type=req.get("type"))
schema = SubmissionSchema(instance=Model()) schema = SubmissionSchema(instance=Model())
response = schema.load(req) response = schema.load(req)
@@ -75,6 +124,16 @@ class SubmissionsList(Resource):
@submissions_namespace.param("submission_id", "A Submission ID") @submissions_namespace.param("submission_id", "A Submission ID")
class Submission(Resource): class Submission(Resource):
@admins_only @admins_only
@submissions_namespace.doc(
description="Endpoint to get submission objects in bulk",
responses={
200: ("Success", "SubmissionDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self, submission_id): def get(self, submission_id):
submission = Submissions.query.filter_by(id=submission_id).first_or_404() submission = Submissions.query.filter_by(id=submission_id).first_or_404()
schema = SubmissionSchema() schema = SubmissionSchema()
@@ -86,6 +145,16 @@ class Submission(Resource):
return {"success": True, "data": response.data} return {"success": True, "data": response.data}
@admins_only @admins_only
@submissions_namespace.doc(
description="Endpoint to get submission objects in bulk",
responses={
200: ("Success", "APISimpleSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def delete(self, submission_id): def delete(self, submission_id):
submission = Submissions.query.filter_by(id=submission_id).first_or_404() submission = Submissions.query.filter_by(id=submission_id).first_or_404()
db.session.delete(submission) db.session.delete(submission)

View File

@@ -1,16 +1,47 @@
from typing import List
from flask import request from flask import request
from flask_restx import Namespace, Resource from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.models import Tags, db from CTFd.models import Tags, db
from CTFd.schemas.tags import TagSchema from CTFd.schemas.tags import TagSchema
from CTFd.utils.decorators import admins_only from CTFd.utils.decorators import admins_only
tags_namespace = Namespace("tags", description="Endpoint to retrieve Tags") tags_namespace = Namespace("tags", description="Endpoint to retrieve Tags")
TagModel = sqlalchemy_to_pydantic(Tags)
class TagDetailedSuccessResponse(APIDetailedSuccessResponse):
data: TagModel
class TagListSuccessResponse(APIListSuccessResponse):
data: List[TagModel]
tags_namespace.schema_model(
"TagDetailedSuccessResponse", TagDetailedSuccessResponse.apidoc()
)
tags_namespace.schema_model("TagListSuccessResponse", TagListSuccessResponse.apidoc())
@tags_namespace.route("") @tags_namespace.route("")
class TagList(Resource): class TagList(Resource):
@admins_only @admins_only
@tags_namespace.doc(
description="Endpoint to list Tag objects in bulk",
responses={
200: ("Success", "TagListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self): def get(self):
# TODO: Filter by challenge_id # TODO: Filter by challenge_id
tags = Tags.query.paginate(max_per_page=100) tags = Tags.query.paginate(max_per_page=100)
@@ -36,6 +67,16 @@ class TagList(Resource):
} }
@admins_only @admins_only
@tags_namespace.doc(
description="Endpoint to create a Tag object",
responses={
200: ("Success", "TagDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self): def post(self):
req = request.get_json() req = request.get_json()
schema = TagSchema() schema = TagSchema()
@@ -57,6 +98,16 @@ class TagList(Resource):
@tags_namespace.param("tag_id", "A Tag ID") @tags_namespace.param("tag_id", "A Tag ID")
class Tag(Resource): class Tag(Resource):
@admins_only @admins_only
@tags_namespace.doc(
description="Endpoint to get a specific Tag object",
responses={
200: ("Success", "TagDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self, tag_id): def get(self, tag_id):
tag = Tags.query.filter_by(id=tag_id).first_or_404() tag = Tags.query.filter_by(id=tag_id).first_or_404()
@@ -68,6 +119,16 @@ class Tag(Resource):
return {"success": True, "data": response.data} return {"success": True, "data": response.data}
@admins_only @admins_only
@tags_namespace.doc(
description="Endpoint to edit a specific Tag object",
responses={
200: ("Success", "TagDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def patch(self, tag_id): def patch(self, tag_id):
tag = Tags.query.filter_by(id=tag_id).first_or_404() tag = Tags.query.filter_by(id=tag_id).first_or_404()
schema = TagSchema() schema = TagSchema()
@@ -85,6 +146,10 @@ class Tag(Resource):
return {"success": True, "data": response.data} return {"success": True, "data": response.data}
@admins_only @admins_only
@tags_namespace.doc(
description="Endpoint to delete a specific Tag object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, tag_id): def delete(self, tag_id):
tag = Tags.query.filter_by(id=tag_id).first_or_404() tag = Tags.query.filter_by(id=tag_id).first_or_404()
db.session.delete(tag) db.session.delete(tag)

View File

@@ -1,8 +1,14 @@
import copy import copy
from typing import List
from flask import abort, request, session from flask import abort, request, session
from flask_restx import Namespace, Resource from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import (
APIDetailedSuccessResponse,
PaginatedAPIListSuccessResponse,
)
from CTFd.cache import clear_standings, clear_team_session, clear_user_session from CTFd.cache import clear_standings, clear_team_session, clear_user_session
from CTFd.models import Awards, Submissions, Teams, Unlocks, Users, db from CTFd.models import Awards, Submissions, Teams, Unlocks, Users, db
from CTFd.schemas.awards import AwardSchema from CTFd.schemas.awards import AwardSchema
@@ -17,10 +23,40 @@ from CTFd.utils.user import get_current_team, get_current_user_type, is_admin
teams_namespace = Namespace("teams", description="Endpoint to retrieve Teams") teams_namespace = Namespace("teams", description="Endpoint to retrieve Teams")
TeamModel = sqlalchemy_to_pydantic(Teams)
TransientTeamModel = sqlalchemy_to_pydantic(Teams, exclude=["id"])
class TeamDetailedSuccessResponse(APIDetailedSuccessResponse):
data: TeamModel
class TeamListSuccessResponse(PaginatedAPIListSuccessResponse):
data: List[TeamModel]
teams_namespace.schema_model(
"TeamDetailedSuccessResponse", TeamDetailedSuccessResponse.apidoc()
)
teams_namespace.schema_model(
"TeamListSuccessResponse", TeamListSuccessResponse.apidoc()
)
@teams_namespace.route("") @teams_namespace.route("")
class TeamList(Resource): class TeamList(Resource):
@check_account_visibility @check_account_visibility
@teams_namespace.doc(
description="Endpoint to get Team objects in bulk",
responses={
200: ("Success", "TeamListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self): def get(self):
if is_admin() and request.args.get("view") == "admin": if is_admin() and request.args.get("view") == "admin":
teams = Teams.query.filter_by().paginate(per_page=50, max_per_page=100) teams = Teams.query.filter_by().paginate(per_page=50, max_per_page=100)
@@ -53,6 +89,16 @@ class TeamList(Resource):
} }
@admins_only @admins_only
@teams_namespace.doc(
description="Endpoint to create a Team object",
responses={
200: ("Success", "TeamDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self): def post(self):
req = request.get_json() req = request.get_json()
user_type = get_current_user_type() user_type = get_current_user_type()
@@ -78,6 +124,16 @@ class TeamList(Resource):
@teams_namespace.param("team_id", "Team ID") @teams_namespace.param("team_id", "Team ID")
class TeamPublic(Resource): class TeamPublic(Resource):
@check_account_visibility @check_account_visibility
@teams_namespace.doc(
description="Endpoint to get a specific Team object",
responses={
200: ("Success", "TeamDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self, team_id): def get(self, team_id):
team = Teams.query.filter_by(id=team_id).first_or_404() team = Teams.query.filter_by(id=team_id).first_or_404()
@@ -97,6 +153,16 @@ class TeamPublic(Resource):
return {"success": True, "data": response.data} return {"success": True, "data": response.data}
@admins_only @admins_only
@teams_namespace.doc(
description="Endpoint to edit a specific Team object",
responses={
200: ("Success", "TeamDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def patch(self, team_id): def patch(self, team_id):
team = Teams.query.filter_by(id=team_id).first_or_404() team = Teams.query.filter_by(id=team_id).first_or_404()
data = request.get_json() data = request.get_json()
@@ -119,6 +185,10 @@ class TeamPublic(Resource):
return {"success": True, "data": response.data} return {"success": True, "data": response.data}
@admins_only @admins_only
@teams_namespace.doc(
description="Endpoint to delete a specific Team object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, team_id): def delete(self, team_id):
team = Teams.query.filter_by(id=team_id).first_or_404() team = Teams.query.filter_by(id=team_id).first_or_404()
team_id = team.id team_id = team.id
@@ -143,6 +213,16 @@ class TeamPublic(Resource):
class TeamPrivate(Resource): class TeamPrivate(Resource):
@authed_only @authed_only
@require_team @require_team
@teams_namespace.doc(
description="Endpoint to get the current user's Team object",
responses={
200: ("Success", "TeamDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self): def get(self):
team = get_current_team() team = get_current_team()
response = TeamSchema(view="self").dump(team) response = TeamSchema(view="self").dump(team)
@@ -156,6 +236,16 @@ class TeamPrivate(Resource):
@authed_only @authed_only
@require_team @require_team
@teams_namespace.doc(
description="Endpoint to edit the current user's Team object",
responses={
200: ("Success", "TeamDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def patch(self): def patch(self):
team = get_current_team() team = get_current_team()
if team.captain_id != session["id"]: if team.captain_id != session["id"]:

View File

@@ -1,8 +1,11 @@
import datetime import datetime
from typing import List
from flask import request, session from flask import request, session
from flask_restx import Namespace, Resource from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.models import Tokens, db from CTFd.models import Tokens, db
from CTFd.schemas.tokens import TokenSchema from CTFd.schemas.tokens import TokenSchema
from CTFd.utils.decorators import authed_only, require_verified_emails from CTFd.utils.decorators import authed_only, require_verified_emails
@@ -11,11 +14,50 @@ from CTFd.utils.user import get_current_user, get_current_user_type, is_admin
tokens_namespace = Namespace("tokens", description="Endpoint to retrieve Tokens") tokens_namespace = Namespace("tokens", description="Endpoint to retrieve Tokens")
TokenModel = sqlalchemy_to_pydantic(Tokens)
ValuelessTokenModel = sqlalchemy_to_pydantic(Tokens, exclude=["value"])
class TokenDetailedSuccessResponse(APIDetailedSuccessResponse):
data: TokenModel
class ValuelessTokenDetailedSuccessResponse(APIDetailedSuccessResponse):
data: ValuelessTokenModel
class TokenListSuccessResponse(APIListSuccessResponse):
data: List[TokenModel]
tokens_namespace.schema_model(
"TokenDetailedSuccessResponse", TokenDetailedSuccessResponse.apidoc()
)
tokens_namespace.schema_model(
"ValuelessTokenDetailedSuccessResponse",
ValuelessTokenDetailedSuccessResponse.apidoc(),
)
tokens_namespace.schema_model(
"TokenListSuccessResponse", TokenListSuccessResponse.apidoc()
)
@tokens_namespace.route("") @tokens_namespace.route("")
class TokenList(Resource): class TokenList(Resource):
@require_verified_emails @require_verified_emails
@authed_only @authed_only
@tokens_namespace.doc(
description="Endpoint to get token objects in bulk",
responses={
200: ("Success", "TokenListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self): def get(self):
user = get_current_user() user = get_current_user()
tokens = Tokens.query.filter_by(user_id=user.id).paginate(max_per_page=100) tokens = Tokens.query.filter_by(user_id=user.id).paginate(max_per_page=100)
@@ -43,6 +85,16 @@ class TokenList(Resource):
@require_verified_emails @require_verified_emails
@authed_only @authed_only
@tokens_namespace.doc(
description="Endpoint to create a token object",
responses={
200: ("Success", "TokenDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self): def post(self):
req = request.get_json() req = request.get_json()
expiration = req.get("expiration") expiration = req.get("expiration")
@@ -67,6 +119,16 @@ class TokenList(Resource):
class TokenDetail(Resource): class TokenDetail(Resource):
@require_verified_emails @require_verified_emails
@authed_only @authed_only
@tokens_namespace.doc(
description="Endpoint to get an existing token object",
responses={
200: ("Success", "ValuelessTokenDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self, token_id): def get(self, token_id):
if is_admin(): if is_admin():
token = Tokens.query.filter_by(id=token_id).first_or_404() token = Tokens.query.filter_by(id=token_id).first_or_404()
@@ -86,6 +148,10 @@ class TokenDetail(Resource):
@require_verified_emails @require_verified_emails
@authed_only @authed_only
@tokens_namespace.doc(
description="Endpoint to delete an existing token object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, token_id): def delete(self, token_id):
if is_admin(): if is_admin():
token = Tokens.query.filter_by(id=token_id).first_or_404() token = Tokens.query.filter_by(id=token_id).first_or_404()

View File

@@ -1,6 +1,10 @@
from typing import List
from flask import request from flask import request
from flask_restx import Namespace, Resource from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.cache import clear_standings from CTFd.cache import clear_standings
from CTFd.models import Unlocks, db, get_class_by_tablename from CTFd.models import Unlocks, db, get_class_by_tablename
from CTFd.schemas.awards import AwardSchema from CTFd.schemas.awards import AwardSchema
@@ -15,10 +19,40 @@ from CTFd.utils.user import get_current_user
unlocks_namespace = Namespace("unlocks", description="Endpoint to retrieve Unlocks") unlocks_namespace = Namespace("unlocks", description="Endpoint to retrieve Unlocks")
UnlockModel = sqlalchemy_to_pydantic(Unlocks)
TransientUnlockModel = sqlalchemy_to_pydantic(Unlocks, exclude=["id"])
class UnlockDetailedSuccessResponse(APIDetailedSuccessResponse):
data: UnlockModel
class UnlockListSuccessResponse(APIListSuccessResponse):
data: List[UnlockModel]
unlocks_namespace.schema_model(
"UnlockDetailedSuccessResponse", UnlockDetailedSuccessResponse.apidoc()
)
unlocks_namespace.schema_model(
"UnlockListSuccessResponse", UnlockListSuccessResponse.apidoc()
)
@unlocks_namespace.route("") @unlocks_namespace.route("")
class UnlockList(Resource): class UnlockList(Resource):
@admins_only @admins_only
@unlocks_namespace.doc(
description="Endpoint to get unlock objects in bulk",
responses={
200: ("Success", "UnlockListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self): def get(self):
unlocks = Unlocks.query.paginate(max_per_page=100) unlocks = Unlocks.query.paginate(max_per_page=100)
schema = UnlockSchema() schema = UnlockSchema()
@@ -45,6 +79,16 @@ class UnlockList(Resource):
@during_ctf_time_only @during_ctf_time_only
@require_verified_emails @require_verified_emails
@authed_only @authed_only
@unlocks_namespace.doc(
description="Endpoint to create an unlock object. Used to unlock hints.",
responses={
200: ("Success", "UnlockDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self): def post(self):
req = request.get_json() req = request.get_json()
user = get_current_user() user = get_current_user()

View File

@@ -1,6 +1,13 @@
from typing import List
from flask import abort, request from flask import abort, request
from flask_restx import Namespace, Resource from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import (
APIDetailedSuccessResponse,
PaginatedAPIListSuccessResponse,
)
from CTFd.cache import clear_standings, clear_user_session from CTFd.cache import clear_standings, clear_user_session
from CTFd.models import ( from CTFd.models import (
Awards, Awards,
@@ -28,15 +35,46 @@ from CTFd.utils.user import get_current_user, get_current_user_type, is_admin
users_namespace = Namespace("users", description="Endpoint to retrieve Users") users_namespace = Namespace("users", description="Endpoint to retrieve Users")
UserModel = sqlalchemy_to_pydantic(Users)
TransientUserModel = sqlalchemy_to_pydantic(Users, exclude=["id"])
class UserDetailedSuccessResponse(APIDetailedSuccessResponse):
data: UserModel
class UserListSuccessResponse(PaginatedAPIListSuccessResponse):
data: List[UserModel]
users_namespace.schema_model(
"UserDetailedSuccessResponse", UserDetailedSuccessResponse.apidoc()
)
users_namespace.schema_model(
"UserListSuccessResponse", UserListSuccessResponse.apidoc()
)
@users_namespace.route("") @users_namespace.route("")
class UserList(Resource): class UserList(Resource):
@check_account_visibility @check_account_visibility
@users_namespace.doc(
description="Endpoint to get User objects in bulk",
responses={
200: ("Success", "UserListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self): def get(self):
if is_admin() and request.args.get("view") == "admin": if is_admin() and request.args.get("view") == "admin":
users = Users.query.filter_by().paginate(max_per_page=100) users = Users.query.filter_by().paginate(per_page=50, max_per_page=100)
else: else:
users = Users.query.filter_by(banned=False, hidden=False).paginate( users = Users.query.filter_by(banned=False, hidden=False).paginate(
max_per_page=100 per_page=50, max_per_page=100
) )
response = UserSchema(view="user", many=True).dump(users.items) response = UserSchema(view="user", many=True).dump(users.items)
@@ -59,12 +97,21 @@ class UserList(Resource):
"data": response.data, "data": response.data,
} }
@users_namespace.doc()
@admins_only
@users_namespace.doc( @users_namespace.doc(
description="Endpoint to create a User object",
responses={
200: ("Success", "UserDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
params={ params={
"notify": "Whether to send the created user an email with their credentials" "notify": "Whether to send the created user an email with their credentials"
} },
) )
@admins_only
def post(self): def post(self):
req = request.get_json() req = request.get_json()
schema = UserSchema("admin") schema = UserSchema("admin")
@@ -94,6 +141,16 @@ class UserList(Resource):
@users_namespace.param("user_id", "User ID") @users_namespace.param("user_id", "User ID")
class UserPublic(Resource): class UserPublic(Resource):
@check_account_visibility @check_account_visibility
@users_namespace.doc(
description="Endpoint to get a specific User object",
responses={
200: ("Success", "UserDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self, user_id): def get(self, user_id):
user = Users.query.filter_by(id=user_id).first_or_404() user = Users.query.filter_by(id=user_id).first_or_404()
@@ -112,6 +169,16 @@ class UserPublic(Resource):
return {"success": True, "data": response.data} return {"success": True, "data": response.data}
@admins_only @admins_only
@users_namespace.doc(
description="Endpoint to edit a specific User object",
responses={
200: ("Success", "UserDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def patch(self, user_id): def patch(self, user_id):
user = Users.query.filter_by(id=user_id).first_or_404() user = Users.query.filter_by(id=user_id).first_or_404()
data = request.get_json() data = request.get_json()
@@ -133,6 +200,10 @@ class UserPublic(Resource):
return {"success": True, "data": response} return {"success": True, "data": response}
@admins_only @admins_only
@users_namespace.doc(
description="Endpoint to delete a specific User object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, user_id): def delete(self, user_id):
Notifications.query.filter_by(user_id=user_id).delete() Notifications.query.filter_by(user_id=user_id).delete()
Awards.query.filter_by(user_id=user_id).delete() Awards.query.filter_by(user_id=user_id).delete()
@@ -153,6 +224,16 @@ class UserPublic(Resource):
@users_namespace.route("/me") @users_namespace.route("/me")
class UserPrivate(Resource): class UserPrivate(Resource):
@authed_only @authed_only
@users_namespace.doc(
description="Endpoint to get the User object for the current user",
responses={
200: ("Success", "UserDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self): def get(self):
user = get_current_user() user = get_current_user()
response = UserSchema("self").dump(user).data response = UserSchema("self").dump(user).data
@@ -161,6 +242,16 @@ class UserPrivate(Resource):
return {"success": True, "data": response} return {"success": True, "data": response}
@authed_only @authed_only
@users_namespace.doc(
description="Endpoint to edit the User object for the current user",
responses={
200: ("Success", "UserDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def patch(self): def patch(self):
user = get_current_user() user = get_current_user()
data = request.get_json() data = request.get_json()
@@ -309,6 +400,10 @@ class UserPublicAwards(Resource):
@users_namespace.param("user_id", "User ID") @users_namespace.param("user_id", "User ID")
class UserEmails(Resource): class UserEmails(Resource):
@admins_only @admins_only
@users_namespace.doc(
description="Endpoint to email a User object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
@ratelimit(method="POST", limit=10, interval=60) @ratelimit(method="POST", limit=10, interval=60)
def post(self, user_id): def post(self, user_id):
req = request.get_json() req = request.get_json()
@@ -329,4 +424,4 @@ class UserEmails(Resource):
result, response = sendmail(addr=user.email, text=text) result, response = sendmail(addr=user.email, text=text)
return {"success": result, "data": {}} return {"success": result}

View File

@@ -18,3 +18,32 @@ TeamAttrs = namedtuple(
"created", "created",
], ],
) )
class _TeamAttrsWrapper:
def __getattr__(self, attr):
from CTFd.utils.user import get_current_team_attrs
attrs = get_current_team_attrs()
return getattr(attrs, attr, None)
@property
def place(self):
from CTFd.utils.user import get_current_team
team = get_current_team()
if team:
return team.place
return None
@property
def score(self):
from CTFd.utils.user import get_current_team
team = get_current_team()
if team:
return team.score
return None
Team = _TeamAttrsWrapper()

View File

@@ -20,3 +20,32 @@ UserAttrs = namedtuple(
"created", "created",
], ],
) )
class _UserAttrsWrapper:
def __getattr__(self, attr):
from CTFd.utils.user import get_current_user_attrs
attrs = get_current_user_attrs()
return getattr(attrs, attr, None)
@property
def place(self):
from CTFd.utils.user import get_current_user
user = get_current_user()
if user:
return user.place
return None
@property
def score(self):
from CTFd.utils.user import get_current_user
user = get_current_user()
if user:
return user.score
return None
User = _UserAttrsWrapper()

View File

@@ -16,9 +16,9 @@ Within CTFd you are free to mix and match regular and dynamic challenges.
The current implementation requires the challenge to keep track of three values: The current implementation requires the challenge to keep track of three values:
* Initial - The original point valuation - Initial - The original point valuation
* Decay - The amount of solves before the challenge will be at the minimum - Decay - The amount of solves before the challenge will be at the minimum
* Minimum - The lowest possible point valuation - Minimum - The lowest possible point valuation
The value decay logic is implemented with the following math: The value decay logic is implemented with the following math:
@@ -50,5 +50,5 @@ so that higher valued challenges have a slower drop from their initial value.
**REQUIRES: CTFd >= v1.2.0** **REQUIRES: CTFd >= v1.2.0**
1. Clone this repository to `CTFd/plugins`. It is important that the folder is 1. Clone this repository to `CTFd/plugins`. It is important that the folder is
named `DynamicValueChallenge` so CTFd can serve the files in the `assets` named `DynamicValueChallenge` so CTFd can serve the files in the `assets`
directory. directory.

View File

@@ -0,0 +1,332 @@
<template>
<div id="media-modal" class="modal fade" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<div class="container">
<div class="row">
<div class="col-md-12">
<h3 class="text-center">Media Library</h3>
</div>
</div>
</div>
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="modal-header">
<div class="container">
<div class="row mh-100">
<div class="col-md-6" id="media-library-list">
<div
class="media-item-wrapper"
v-for="file in files"
:key="file.id"
>
<a
href="javascript:void(0)"
@click="
selectFile(file);
return false;
"
>
<i
v-bind:class="getIconClass(file.location)"
aria-hidden="true"
></i>
<small class="media-item-title">{{
file.location.split("/").pop()
}}</small>
</a>
</div>
</div>
<div class="col-md-6" id="media-library-details">
<h4 class="text-center">Media Details</h4>
<div id="media-item">
<div class="text-center" id="media-icon">
<div v-if="this.selectedFile">
<div
v-if="
getIconClass(this.selectedFile.location) ===
'far fa-file-image'
"
>
<img
v-bind:src="buildSelectedFileUrl()"
style="max-width: 100%; max-height: 100%; object-fit: contain;"
/>
</div>
<div v-else>
<i
v-bind:class="
`${getIconClass(
this.selectedFile.location
)} fa-4x`
"
aria-hidden="true"
></i>
</div>
</div>
</div>
<br />
<div
class="text-center"
id="media-filename"
v-if="this.selectedFile"
>
<a v-bind:href="buildSelectedFileUrl()" target="_blank">
{{ this.selectedFile.location.split("/").pop() }}
</a>
</div>
<br />
<div class="form-group">
<div v-if="this.selectedFile">
Link:
<input
class="form-control"
type="text"
id="media-link"
v-bind:value="buildSelectedFileUrl()"
readonly
/>
</div>
<div v-else>
Link:
<input
class="form-control"
type="text"
id="media-link"
readonly
/>
</div>
</div>
<div class="form-group text-center">
<div class="row">
<div class="col-md-6">
<button
@click="insertSelectedFile"
class="btn btn-success w-100"
id="media-insert"
data-toggle="tooltip"
data-placement="top"
title="Insert link into editor"
>
Insert
</button>
</div>
<div class="col-md-3">
<button
@click="downloadSelectedFile"
class="btn btn-primary w-100"
id="media-download"
data-toggle="tooltip"
data-placement="top"
title="Download file"
>
<i class="fas fa-download"></i>
</button>
</div>
<div class="col-md-3">
<button
@click="deleteSelectedFile"
class="btn btn-danger w-100"
id="media-delete"
data-toggle="tooltip"
data-placement="top"
title="Delete file"
>
<i class="far fa-trash-alt"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<form id="media-library-upload" enctype="multipart/form-data">
<div class="form-group">
<label for="media-files">
Upload Files
</label>
<input
type="file"
name="file"
id="media-files"
class="form-control-file"
multiple
/>
<sub class="help-block">
Attach multiple files using Control+Click or Cmd+Click.
</sub>
</div>
<input type="hidden" value="page" name="type" />
</form>
</div>
<div class="modal-footer">
<div class="float-right">
<button
@click="uploadChosenFiles"
type="submit"
class="btn btn-primary media-upload-button"
>
Upload
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import CTFd from "core/CTFd";
import { ezQuery, ezToast } from "core/ezq";
import { default as helpers } from "core/helpers";
function get_page_files() {
return CTFd.fetch("/api/v1/files?type=page", {
credentials: "same-origin"
}).then(function(response) {
return response.json();
});
}
export default {
props: {
editor: Object
},
data: function() {
return {
files: [],
selectedFile: null
};
},
methods: {
getPageFiles: function() {
get_page_files().then(response => {
this.files = response.data;
return this.files;
});
},
uploadChosenFiles: function() {
// TODO: We should reduce the need to interact with the DOM directly.
// This looks jank and we should be able to remove it.
let form = document.querySelector("#media-library-upload");
helpers.files.upload(form, {}, _data => {
this.getPageFiles();
});
},
selectFile: function(file) {
this.selectedFile = file;
return this.selectedFile;
},
buildSelectedFileUrl: function() {
return CTFd.config.urlRoot + "/files/" + this.selectedFile.location;
},
deleteSelectedFile: function() {
var file_id = this.selectedFile.id;
if (confirm("Are you sure you want to delete this file?")) {
CTFd.fetch("/api/v1/files/" + file_id, {
method: "DELETE"
}).then(response => {
if (response.status === 200) {
response.json().then(object => {
if (object.success) {
this.getPageFiles();
this.selectedFile = null;
}
});
}
});
}
},
insertSelectedFile: function() {
let editor = this.$props.editor;
if (editor.hasOwnProperty("codemirror")) {
editor = editor.codemirror;
}
let doc = editor.getDoc();
let cursor = doc.getCursor();
let url = this.buildSelectedFileUrl();
let img =
this.getIconClass(this.selectedFile.location) === "far fa-file-image";
let filename = url.split("/").pop();
link = "[{0}]({1})".format(filename, url);
if (img) {
link = "!" + link;
}
doc.replaceRange(link, cursor);
},
downloadSelectedFile: function() {
var link = this.buildSelectedFileUrl();
window.open(link, "_blank");
},
getIconClass: function(filename) {
var mapping = {
// Image Files
png: "far fa-file-image",
jpg: "far fa-file-image",
jpeg: "far fa-file-image",
gif: "far fa-file-image",
bmp: "far fa-file-image",
svg: "far fa-file-image",
// Text Files
txt: "far fa-file-alt",
// Video Files
mov: "far fa-file-video",
mp4: "far fa-file-video",
wmv: "far fa-file-video",
flv: "far fa-file-video",
mkv: "far fa-file-video",
avi: "far fa-file-video",
// PDF Files
pdf: "far fa-file-pdf",
// Audio Files
mp3: "far fa-file-sound",
wav: "far fa-file-sound",
aac: "far fa-file-sound",
// Archive Files
zip: "far fa-file-archive",
gz: "far fa-file-archive",
tar: "far fa-file-archive",
"7z": "far fa-file-archive",
rar: "far fa-file-archive",
// Code Files
py: "far fa-file-code",
c: "far fa-file-code",
cpp: "far fa-file-code",
html: "far fa-file-code",
js: "far fa-file-code",
rb: "far fa-file-code",
go: "far fa-file-code"
};
var ext = filename.split(".").pop();
return mapping[ext] || "far fa-file";
}
},
created() {
return this.getPageFiles();
}
};
</script>

View File

@@ -1,155 +1,11 @@
import "./main"; import "./main";
import { showMediaLibrary } from "../styles";
import "core/utils"; import "core/utils";
import $ from "jquery"; import $ from "jquery";
import CTFd from "core/CTFd"; import CTFd from "core/CTFd";
import { default as helpers } from "core/helpers";
import CodeMirror from "codemirror"; import CodeMirror from "codemirror";
import "codemirror/mode/htmlmixed/htmlmixed.js"; import "codemirror/mode/htmlmixed/htmlmixed.js";
import { ezQuery, ezToast } from "core/ezq"; import { ezToast } from "core/ezq";
function get_filetype_icon_class(filename) {
var mapping = {
// Image Files
png: "fa-file-image",
jpg: "fa-file-image",
jpeg: "fa-file-image",
gif: "fa-file-image",
bmp: "fa-file-image",
svg: "fa-file-image",
// Text Files
txt: "fa-file-alt",
// Video Files
mov: "fa-file-video",
mp4: "fa-file-video",
wmv: "fa-file-video",
flv: "fa-file-video",
mkv: "fa-file-video",
avi: "fa-file-video",
// PDF Files
pdf: "fa-file-pdf",
// Audio Files
mp3: "fa-file-sound",
wav: "fa-file-sound",
aac: "fa-file-sound",
// Archive Files
zip: "fa-file-archive",
gz: "fa-file-archive",
tar: "fa-file-archive",
"7z": "fa-file-archive",
rar: "fa-file-archive",
// Code Files
py: "fa-file-code",
c: "fa-file-code",
cpp: "fa-file-code",
html: "fa-file-code",
js: "fa-file-code",
rb: "fa-file-code",
go: "fa-file-code"
};
var ext = filename.split(".").pop();
return mapping[ext];
}
function get_page_files() {
return CTFd.fetch("/api/v1/files?type=page", {
credentials: "same-origin"
}).then(function(response) {
return response.json();
});
}
function show_files(data) {
var list = $("#media-library-list");
list.empty();
for (var i = 0; i < data.length; i++) {
var f = data[i];
var fname = f.location.split("/").pop();
var ext = get_filetype_icon_class(f.location);
var wrapper = $("<div>").attr("class", "media-item-wrapper");
var link = $("<a>");
link.attr("href", "##");
if (ext === undefined) {
link.append(
'<i class="far fa-file" aria-hidden="true"></i> '.format(ext)
);
} else {
link.append('<i class="far {0}" aria-hidden="true"></i> '.format(ext));
}
link.append(
$("<small>")
.attr("class", "media-item-title")
.text(fname)
);
link.click(function(_e) {
var media_div = $(this).parent();
var icon = $(this).find("i")[0];
var f_loc = media_div.attr("data-location");
var fname = media_div.attr("data-filename");
var f_id = media_div.attr("data-id");
$("#media-delete").attr("data-id", f_id);
$("#media-link").val(f_loc);
$("#media-filename").html(
$("<a>")
.attr("href", f_loc)
.attr("target", "_blank")
.text(fname)
);
$("#media-icon").empty();
if ($(icon).hasClass("fa-file-image")) {
$("#media-icon").append(
$("<img>")
.attr("src", f_loc)
.css({
"max-width": "100%",
"max-height": "100%",
"object-fit": "contain"
})
);
} else {
// icon is empty so we need to pull outerHTML
var copy_icon = $(icon).clone();
$(copy_icon).addClass("fa-4x");
$("#media-icon").append(copy_icon);
}
$("#media-item").show();
});
wrapper.append(link);
wrapper.attr("data-location", CTFd.config.urlRoot + "/files/" + f.location);
wrapper.attr("data-id", f.id);
wrapper.attr("data-filename", fname);
list.append(wrapper);
}
}
function refresh_files(cb) {
get_page_files().then(function(response) {
var data = response.data;
show_files(data);
if (cb) {
cb();
}
});
}
function insert_at_cursor(editor, text) {
var doc = editor.getDoc();
var cursor = doc.getCursor();
doc.replaceRange(text, cursor);
}
function submit_form() { function submit_form() {
// Save the CodeMirror data to the Textarea // Save the CodeMirror data to the Textarea
@@ -196,12 +52,6 @@ function preview_page() {
$("#page-edit").submit(); $("#page-edit").submit();
} }
function upload_media() {
helpers.files.upload($("#media-library-upload"), {}, function(_data) {
refresh_files();
});
}
$(() => { $(() => {
window.editor = CodeMirror.fromTextArea( window.editor = CodeMirror.fromTextArea(
document.getElementById("admin-pages-editor"), document.getElementById("admin-pages-editor"),
@@ -213,55 +63,8 @@ $(() => {
} }
); );
$("#media-insert").click(function(_e) { $("#media-button").click(function(_e) {
var tag = ""; showMediaLibrary(window.editor);
try {
tag = $("#media-icon")
.children()[0]
.nodeName.toLowerCase();
} catch (err) {
tag = "";
}
var link = $("#media-link").val();
var fname = $("#media-filename").text();
var entry = null;
if (tag === "img") {
entry = "![{0}]({1})".format(fname, link);
} else {
entry = "[{0}]({1})".format(fname, link);
}
insert_at_cursor(window.editor, entry);
});
$("#media-download").click(function(_e) {
var link = $("#media-link").val();
window.open(link, "_blank");
});
$("#media-delete").click(function(_e) {
var file_id = $(this).attr("data-id");
ezQuery({
title: "Delete File?",
body: "Are you sure you want to delete this file?",
success: function() {
CTFd.fetch("/api/v1/files/" + file_id, {
method: "DELETE",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
}
}).then(function(response) {
if (response.status === 200) {
response.json().then(function(object) {
if (object.success) {
refresh_files();
}
});
}
});
}
});
}); });
$("#save-page").click(function(e) { $("#save-page").click(function(e) {
@@ -269,17 +72,6 @@ $(() => {
submit_form(); submit_form();
}); });
$("#media-button").click(function() {
$("#media-library-list").empty();
refresh_files(function() {
$("#media-modal").modal();
});
});
$(".media-upload-button").click(function() {
upload_media();
});
$(".preview-page").click(function() { $(".preview-page").click(function() {
preview_page(); preview_page();
}); });

View File

@@ -2,6 +2,33 @@ import "bootstrap/dist/js/bootstrap.bundle";
import { makeSortableTables } from "core/utils"; import { makeSortableTables } from "core/utils";
import $ from "jquery"; import $ from "jquery";
import EasyMDE from "easymde"; import EasyMDE from "easymde";
import Vue from "vue/dist/vue.esm.browser";
import MediaLibrary from "./components/files/MediaLibrary.vue";
export function showMediaLibrary(editor) {
const mediaModal = Vue.extend(MediaLibrary);
// Create an empty div and append it to our <main>
let vueContainer = document.createElement("div");
document.querySelector("main").appendChild(vueContainer);
// Create MediaLibrary component and pass it our editor
let m = new mediaModal({
propsData: {
editor: editor
}
// Mount to the empty div
}).$mount(vueContainer);
// Destroy the Vue instance and the media modal when closed
$("#media-modal").on("hidden.bs.modal", function(_e) {
m.$destroy();
$("#media-modal").remove();
});
// Pop the Component modal
$("#media-modal").modal();
}
export function bindMarkdownEditors() { export function bindMarkdownEditors() {
$("textarea.markdown").each(function(_i, e) { $("textarea.markdown").each(function(_i, e) {
@@ -19,6 +46,14 @@ export function bindMarkdownEditors() {
"|", "|",
"link", "link",
"image", "image",
{
name: "media",
action: editor => {
showMediaLibrary(editor);
},
className: "fas fa-file-upload",
title: "Media Library"
},
"|", "|",
"preview", "preview",
"guide" "guide"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,94 +4,6 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div id="media-modal" class="modal fade" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<div class="container">
<div class="row">
<div class="col-md-12">
<h3 class="text-center">Media Library</h3>
</div>
</div>
</div>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="modal-header">
<div class="container">
<div class="row mh-100">
<div class="col-md-6" id="media-library-list">
</div>
<div class="col-md-6" id="media-library-details">
<h4 class="text-center">Media Details</h4>
<div id="media-item">
<div class="text-center" id="media-icon">
</div>
<br>
<div class="text-center" id="media-filename">
</div>
<br>
<div class="form-group">
Link: <input class="form-control" type="text" id="media-link" readonly>
</div>
<div class="form-group text-center">
<div class="row">
<div class="col-md-6">
<button class="btn btn-success w-100" id="media-insert"
data-toggle="tooltip" data-placement="top"
title="Insert link into editor">
Insert
</button>
</div>
<div class="col-md-3">
<button class="btn btn-primary w-100" id="media-download"
data-toggle="tooltip" data-placement="top"
title="Download file">
<i class="fas fa-download"></i>
</button>
</div>
<div class="col-md-3">
<button class="btn btn-danger w-100" id="media-delete"
data-toggle="tooltip" data-placement="top"
title="Delete file">
<i class="far fa-trash-alt"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% with form = Forms.pages.PageFilesUploadForm() %}
<form id="media-library-upload" enctype="multipart/form-data">
<div class="form-group">
<b>{{ form.file.label }}</b>
{{ form.file(id="media-files", class="form-control-file") }}
<sub class="help-block">
{{ form.file.description }}
</sub>
</div>
<input type="hidden" value="page" name="type">
</form>
{% endwith %}
</div>
<div class="modal-footer">
<div class="float-right">
<button type="submit" class="btn btn-primary media-upload-button">Upload</button>
</div>
</div>
</div>
</div>
</div>
<div class="container"> <div class="container">
<div class="row pt-5"> <div class="row pt-5">
<div class="col-md-12"> <div class="col-md-12">

View File

@@ -83,6 +83,16 @@
{% endfor %} {% endfor %}
</div> </div>
{% if max_attempts > 1 %}
<div class="row text-center">
<div class="col-md-12">
<p>
{{ attempts }}/{{ max_attempts }} attempt{{ attempts|pluralize(attempts) }}
</p>
</div>
</div>
{% endif %}
<div class="row submit-row"> <div class="row submit-row">
<div class="col-md-9 form-group"> <div class="col-md-9 form-group">
{% block input %} {% block input %}

View File

@@ -0,0 +1,5 @@
def pluralize(number, singular="", plural="s"):
if number == 1:
return singular
else:
return plural

View File

@@ -23,6 +23,7 @@ from CTFd.utils.config.pages import get_pages
from CTFd.utils.countries import get_countries, lookup_country_code from CTFd.utils.countries import get_countries, lookup_country_code
from CTFd.utils.dates import isoformat, unix_time, unix_time_millis from CTFd.utils.dates import isoformat, unix_time, unix_time_millis
from CTFd.utils.events import EventManager, RedisEventManager from CTFd.utils.events import EventManager, RedisEventManager
from CTFd.utils.humanize.words import pluralize
from CTFd.utils.modes import generate_account_url, get_mode_as_word from CTFd.utils.modes import generate_account_url, get_mode_as_word
from CTFd.utils.plugins import ( from CTFd.utils.plugins import (
get_configurable_plugins, get_configurable_plugins,
@@ -48,12 +49,15 @@ def init_template_filters(app):
app.jinja_env.filters["unix_time"] = unix_time app.jinja_env.filters["unix_time"] = unix_time
app.jinja_env.filters["unix_time_millis"] = unix_time_millis app.jinja_env.filters["unix_time_millis"] = unix_time_millis
app.jinja_env.filters["isoformat"] = isoformat app.jinja_env.filters["isoformat"] = isoformat
app.jinja_env.filters["pluralize"] = pluralize
def init_template_globals(app): def init_template_globals(app):
from CTFd.constants.config import Configs from CTFd.constants.config import Configs
from CTFd.constants.plugins import Plugins from CTFd.constants.plugins import Plugins
from CTFd.constants.sessions import Session from CTFd.constants.sessions import Session
from CTFd.constants.users import User
from CTFd.constants.teams import Team
from CTFd.forms import Forms from CTFd.forms import Forms
from CTFd.utils.config.visibility import ( from CTFd.utils.config.visibility import (
accounts_visible, accounts_visible,
@@ -96,6 +100,8 @@ def init_template_globals(app):
app.jinja_env.globals.update(Plugins=Plugins) app.jinja_env.globals.update(Plugins=Plugins)
app.jinja_env.globals.update(Session=Session) app.jinja_env.globals.update(Session=Session)
app.jinja_env.globals.update(Forms=Forms) app.jinja_env.globals.update(Forms=Forms)
app.jinja_env.globals.update(User=User)
app.jinja_env.globals.update(Team=Team)
def init_logs(app): def init_logs(app):

View File

@@ -3,11 +3,13 @@ lint:
yarn lint yarn lint
black --check --exclude=CTFd/uploads --exclude=node_modules . black --check --exclude=CTFd/uploads --exclude=node_modules .
prettier --check 'CTFd/themes/**/assets/**/*' prettier --check 'CTFd/themes/**/assets/**/*'
prettier --check '**/*.md'
format: format:
isort --skip=CTFd/uploads -rc CTFd/ tests/ isort --skip=CTFd/uploads -rc CTFd/ tests/
black --exclude=CTFd/uploads --exclude=node_modules . black --exclude=CTFd/uploads --exclude=node_modules .
prettier --write 'CTFd/themes/**/assets/**/*' prettier --write 'CTFd/themes/**/assets/**/*'
prettier --write '**/*.md'
test: test:
pytest -rf --cov=CTFd --cov-context=test --ignore=node_modules/ \ pytest -rf --cov=CTFd --cov-context=test --ignore=node_modules/ \

View File

@@ -1,47 +1,49 @@
![](https://github.com/CTFd/CTFd/blob/master/CTFd/themes/core/static/img/logo.png?raw=true) # ![](https://github.com/CTFd/CTFd/blob/master/CTFd/themes/core/static/img/logo.png?raw=true)
====
[![Build Status](https://travis-ci.org/CTFd/CTFd.svg?branch=master)](https://travis-ci.org/CTFd/CTFd) [![Build Status](https://travis-ci.org/CTFd/CTFd.svg?branch=master)](https://travis-ci.org/CTFd/CTFd)
[![MajorLeagueCyber Discourse](https://img.shields.io/discourse/status?server=https%3A%2F%2Fcommunity.majorleaguecyber.org%2F)](https://community.majorleaguecyber.org/) [![MajorLeagueCyber Discourse](https://img.shields.io/discourse/status?server=https%3A%2F%2Fcommunity.majorleaguecyber.org%2F)](https://community.majorleaguecyber.org/)
[![Documentation Status](https://readthedocs.org/projects/ctfd/badge/?version=latest)](https://docs.ctfd.io/en/latest/?badge=latest) [![Documentation Status](https://readthedocs.org/projects/ctfd/badge/?version=latest)](https://docs.ctfd.io/en/latest/?badge=latest)
## What is CTFd? ## What is CTFd?
CTFd is a Capture The Flag framework focusing on ease of use and customizability. It comes with everything you need to run a CTF and it's easy to customize with plugins and themes. CTFd is a Capture The Flag framework focusing on ease of use and customizability. It comes with everything you need to run a CTF and it's easy to customize with plugins and themes.
![CTFd is a CTF in a can.](https://github.com/CTFd/CTFd/blob/master/CTFd/themes/core/static/img/scoreboard.png?raw=true) ![CTFd is a CTF in a can.](https://github.com/CTFd/CTFd/blob/master/CTFd/themes/core/static/img/scoreboard.png?raw=true)
## Features ## Features
* Create your own challenges, categories, hints, and flags from the Admin Interface
* Dynamic Scoring Challenges - Create your own challenges, categories, hints, and flags from the Admin Interface
* Unlockable challenge support - Dynamic Scoring Challenges
* Challenge plugin architecture to create your own custom challenges - Unlockable challenge support
* Static & Regex based flags - Challenge plugin architecture to create your own custom challenges
* Custom flag plugins - Static & Regex based flags
* Unlockable hints - Custom flag plugins
* File uploads to the server or an Amazon S3-compatible backend - Unlockable hints
* Limit challenge attempts & hide challenges - File uploads to the server or an Amazon S3-compatible backend
* Automatic bruteforce protection - Limit challenge attempts & hide challenges
* Individual and Team based competitions - Automatic bruteforce protection
* Have users play on their own or form teams to play together - Individual and Team based competitions
* Scoreboard with automatic tie resolution - Have users play on their own or form teams to play together
* Hide Scores from the public - Scoreboard with automatic tie resolution
* Freeze Scores at a specific time - Hide Scores from the public
* Scoregraphs comparing the top 10 teams and team progress graphs - Freeze Scores at a specific time
* Markdown content management system - Scoregraphs comparing the top 10 teams and team progress graphs
* SMTP + Mailgun email support - Markdown content management system
* Email confirmation support - SMTP + Mailgun email support
* Forgot password support - Email confirmation support
* Automatic competition starting and ending - Forgot password support
* Team management, hiding, and banning - Automatic competition starting and ending
* Customize everything using the [plugin](https://github.com/CTFd/CTFd/wiki/Plugins) and [theme](https://github.com/CTFd/CTFd/tree/master/CTFd/themes) interfaces - Team management, hiding, and banning
* Importing and Exporting of CTF data for archival - Customize everything using the [plugin](https://github.com/CTFd/CTFd/wiki/Plugins) and [theme](https://github.com/CTFd/CTFd/tree/master/CTFd/themes) interfaces
* And a lot more... - Importing and Exporting of CTF data for archival
- And a lot more...
## Install ## Install
1. Install dependencies: `pip install -r requirements.txt`
1. You can also use the `prepare.sh` script to install system dependencies using apt. 1. Install dependencies: `pip install -r requirements.txt`
2. Modify [CTFd/config.py](https://github.com/CTFd/CTFd/blob/master/CTFd/config.py) to your liking. 1. You can also use the `prepare.sh` script to install system dependencies using apt.
3. Use `flask run` in a terminal to drop into debug mode. 2. Modify [CTFd/config.py](https://github.com/CTFd/CTFd/blob/master/CTFd/config.py) to your liking.
3. Use `flask run` in a terminal to drop into debug mode.
You can use the auto-generated Docker images with the following command: You can use the auto-generated Docker images with the following command:
@@ -54,17 +56,21 @@ Or you can use Docker Compose with the following command from the source reposit
Check out the [wiki](https://github.com/CTFd/CTFd/wiki) for [deployment options](https://github.com/CTFd/CTFd/wiki/Basic-Deployment) and the [Getting Started](https://github.com/CTFd/CTFd/wiki/Getting-Started) guide Check out the [wiki](https://github.com/CTFd/CTFd/wiki) for [deployment options](https://github.com/CTFd/CTFd/wiki/Basic-Deployment) and the [Getting Started](https://github.com/CTFd/CTFd/wiki/Getting-Started) guide
## Live Demo ## Live Demo
https://demo.ctfd.io/ https://demo.ctfd.io/
## Support ## Support
To get basic support, you can join the [MajorLeagueCyber Community](https://community.majorleaguecyber.org/): [![MajorLeagueCyber Discourse](https://img.shields.io/discourse/status?server=https%3A%2F%2Fcommunity.majorleaguecyber.org%2F)](https://community.majorleaguecyber.org/) To get basic support, you can join the [MajorLeagueCyber Community](https://community.majorleaguecyber.org/): [![MajorLeagueCyber Discourse](https://img.shields.io/discourse/status?server=https%3A%2F%2Fcommunity.majorleaguecyber.org%2F)](https://community.majorleaguecyber.org/)
If you prefer commercial support or have a special project, feel free to [contact us](https://ctfd.io/contact/). If you prefer commercial support or have a special project, feel free to [contact us](https://ctfd.io/contact/).
## Managed Hosting ## Managed Hosting
Looking to use CTFd but don't want to deal with managing infrastructure? Check out [the CTFd website](https://ctfd.io/) for managed CTFd deployments. Looking to use CTFd but don't want to deal with managing infrastructure? Check out [the CTFd website](https://ctfd.io/) for managed CTFd deployments.
## MajorLeagueCyber ## MajorLeagueCyber
CTFd is heavily integrated with [MajorLeagueCyber](https://majorleaguecyber.org/). MajorLeagueCyber (MLC) is a CTF stats tracker that provides event scheduling, team tracking, and single sign on for events. CTFd is heavily integrated with [MajorLeagueCyber](https://majorleaguecyber.org/). MajorLeagueCyber (MLC) is a CTF stats tracker that provides event scheduling, team tracking, and single sign on for events.
By registering your CTF event with MajorLeagueCyber users can automatically login, track their individual and team scores, submit writeups, and get notifications of important events. By registering your CTF event with MajorLeagueCyber users can automatically login, track their individual and team scores, submit writeups, and get notifications of important events.
@@ -77,6 +83,7 @@ OAUTH_CLIENT_SECRET = None
``` ```
## Credits ## Credits
* Logo by [Laura Barbera](http://www.laurabb.com/)
* Theme by [Christopher Thompson](https://github.com/breadchris) - Logo by [Laura Barbera](http://www.laurabb.com/)
* Notification Sound by [Terrence Martin](https://soundcloud.com/tj-martin-composer) - Theme by [Christopher Thompson](https://github.com/breadchris)
- Notification Sound by [Terrence Martin](https://soundcloud.com/tj-martin-composer)

View File

@@ -31,7 +31,7 @@
"bootstrap": "~4.3.1", "bootstrap": "~4.3.1",
"bootstrap-multimodal": "~1.0.4", "bootstrap-multimodal": "~1.0.4",
"codemirror": "~5.42.2", "codemirror": "~5.42.2",
"css-loader": "~2.1.0", "css-loader": "^3.6.0",
"easymde": "^2.10.1", "easymde": "^2.10.1",
"echarts": "^4.8.0", "echarts": "^4.8.0",
"eslint": "~5.12.0", "eslint": "~5.12.0",
@@ -54,6 +54,10 @@
"typeface-lato": "~0.0.54", "typeface-lato": "~0.0.54",
"typeface-raleway": "~0.0.54", "typeface-raleway": "~0.0.54",
"uglifyjs-webpack-plugin": "~2.1.1", "uglifyjs-webpack-plugin": "~2.1.1",
"vue": "^2.6.11",
"vue-loader": "15.9.3",
"vue-style-loader": "^4.1.2",
"vue-template-compiler": "^2.6.11",
"webpack": "~4.28.1", "webpack": "~4.28.1",
"webpack-cli": "~3.2.1", "webpack-cli": "~3.2.1",
"webpack-fix-style-only-entries": "~0.3.0", "webpack-fix-style-only-entries": "~0.3.0",

View File

@@ -22,6 +22,7 @@ flask-marshmallow==0.10.1
marshmallow-sqlalchemy==0.17.0 marshmallow-sqlalchemy==0.17.0
boto3==1.13.9 boto3==1.13.9
marshmallow==2.20.2 marshmallow==2.20.2
pydantic==1.5.1
lxml==4.5.1 lxml==4.5.1
html5lib==1.0.1 html5lib==1.0.1
WTForms==2.3.1 WTForms==2.3.1

View File

@@ -6,6 +6,7 @@ const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin')
const UglifyJsPlugin = require('uglifyjs-webpack-plugin') const UglifyJsPlugin = require('uglifyjs-webpack-plugin')
const RemoveStrictPlugin = require('remove-strict-webpack-plugin') const RemoveStrictPlugin = require('remove-strict-webpack-plugin')
const WebpackShellPlugin = require('webpack-shell-plugin'); const WebpackShellPlugin = require('webpack-shell-plugin');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const roots = { const roots = {
'themes/core': { 'themes/core': {
@@ -132,9 +133,25 @@ function getJSConfig(root, type, entries, mode) {
} }
} }
}, },
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
loaders: {
css: ['vue-style-loader', {
loader: 'css-loader',
}],
js: [
'babel-loader',
],
},
cacheBusting: true,
},
}
], ],
}, },
plugins: [ plugins: [
new VueLoaderPlugin(),
new webpack.NamedModulesPlugin(), new webpack.NamedModulesPlugin(),
new RemoveStrictPlugin(), new RemoveStrictPlugin(),
// Identify files that are generated in development but not in production and create stubs to avoid a 404 // Identify files that are generated in development but not in production and create stubs to avoid a 404
@@ -191,6 +208,13 @@ function getCSSConfig(root, type, entries, mode) {
} }
] ]
}, },
// {
// test: /\.css$/,
// use: [
// 'vue-style-loader',
// 'css-loader'
// ]
// },
{ {
test: /\.(s?)css$/, test: /\.(s?)css$/,
use: [ use: [

206
yarn.lock
View File

@@ -599,11 +599,32 @@
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.11.2.tgz#8644bc25b19475779a7b7c1fc104bc0a794f4465" resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.11.2.tgz#8644bc25b19475779a7b7c1fc104bc0a794f4465"
integrity sha512-XiUPoS79r1G7PcpnNtq85TJ7inJWe0v+b5oZJZKb0pGHNIV6+UiNeQWiFGmuQ0aj7GEhnD/v9iqxIsjuRKtEnQ== integrity sha512-XiUPoS79r1G7PcpnNtq85TJ7inJWe0v+b5oZJZKb0pGHNIV6+UiNeQWiFGmuQ0aj7GEhnD/v9iqxIsjuRKtEnQ==
"@types/json-schema@^7.0.4":
version "7.0.5"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.5.tgz#dcce4430e64b443ba8945f0290fb564ad5bac6dd"
integrity sha512-7+2BITlgjgDhH0vvwZU/HZJVyk+2XUlvxXe8dFMedNX/aMkaOq++rMAFXc0tM7ij15QaWlbdQASBR9dihi+bDQ==
"@types/q@^1.5.1": "@types/q@^1.5.1":
version "1.5.2" version "1.5.2"
resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8" resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8"
integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw== integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw==
"@vue/component-compiler-utils@^3.1.0":
version "3.1.2"
resolved "https://registry.yarnpkg.com/@vue/component-compiler-utils/-/component-compiler-utils-3.1.2.tgz#8213a5ff3202f9f2137fe55370f9e8b9656081c3"
integrity sha512-QLq9z8m79mCinpaEeSURhnNCN6djxpHw0lpP/bodMlt5kALfONpryMthvnrQOlTcIKoF+VoPi+lPHUYeDFPXug==
dependencies:
consolidate "^0.15.1"
hash-sum "^1.0.2"
lru-cache "^4.1.2"
merge-source-map "^1.1.0"
postcss "^7.0.14"
postcss-selector-parser "^6.0.2"
source-map "~0.6.1"
vue-template-es2015-compiler "^1.9.0"
optionalDependencies:
prettier "^1.18.2"
"@webassemblyjs/ast@1.7.11": "@webassemblyjs/ast@1.7.11":
version "1.7.11" version "1.7.11"
resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.7.11.tgz#b988582cafbb2b095e8b556526f30c90d057cace" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.7.11.tgz#b988582cafbb2b095e8b556526f30c90d057cace"
@@ -799,6 +820,11 @@ ajv-keywords@^3.1.0:
resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.4.0.tgz#4b831e7b531415a7cc518cd404e73f6193c6349d" resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.4.0.tgz#4b831e7b531415a7cc518cd404e73f6193c6349d"
integrity sha512-aUjdRFISbuFOl0EIZc+9e4FfZp0bDZgAdOOf30bJmw8VM9v84SHyVyxDfbWxpGYbdZD/9XoKxfHVNmxPkhwyGw== integrity sha512-aUjdRFISbuFOl0EIZc+9e4FfZp0bDZgAdOOf30bJmw8VM9v84SHyVyxDfbWxpGYbdZD/9XoKxfHVNmxPkhwyGw==
ajv-keywords@^3.4.1:
version "3.5.0"
resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.0.tgz#5c894537098785926d71e696114a53ce768ed773"
integrity sha512-eyoaac3btgU8eJlvh01En8OCKzRqlLe2G5jDsCr3RiE2uLGMEEB1aaGwVVpwR8M95956tGH6R+9edC++OvzaVw==
ajv@^6.1.0, ajv@^6.5.3, ajv@^6.9.1: ajv@^6.1.0, ajv@^6.5.3, ajv@^6.9.1:
version "6.10.0" version "6.10.0"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.0.tgz#90d0d54439da587cd7e843bfb7045f50bd22bdf1" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.0.tgz#90d0d54439da587cd7e843bfb7045f50bd22bdf1"
@@ -809,7 +835,7 @@ ajv@^6.1.0, ajv@^6.5.3, ajv@^6.9.1:
json-schema-traverse "^0.4.1" json-schema-traverse "^0.4.1"
uri-js "^4.2.2" uri-js "^4.2.2"
ajv@^6.5.5: ajv@^6.12.2, ajv@^6.5.5:
version "6.12.2" version "6.12.2"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.2.tgz#c629c5eced17baf314437918d2da88c99d5958cd" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.2.tgz#c629c5eced17baf314437918d2da88c99d5958cd"
integrity sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ== integrity sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ==
@@ -1064,6 +1090,11 @@ block-stream@*:
dependencies: dependencies:
inherits "~2.0.0" inherits "~2.0.0"
bluebird@^3.1.1:
version "3.7.2"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
bluebird@^3.5.5: bluebird@^3.5.5:
version "3.5.5" version "3.5.5"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.5.tgz#a8d0afd73251effbbd5fe384a77d73003c17a71f" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.5.tgz#a8d0afd73251effbbd5fe384a77d73003c17a71f"
@@ -1300,7 +1331,7 @@ camelcase@^4.0.0:
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd"
integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0= integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=
camelcase@^5.0.0, camelcase@^5.2.0: camelcase@^5.0.0, camelcase@^5.3.1:
version "5.3.1" version "5.3.1"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
@@ -1616,6 +1647,13 @@ console-control-strings@^1.0.0, console-control-strings@~1.1.0:
resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=
consolidate@^0.15.1:
version "0.15.1"
resolved "https://registry.yarnpkg.com/consolidate/-/consolidate-0.15.1.tgz#21ab043235c71a07d45d9aad98593b0dba56bab7"
integrity sha512-DW46nrsMJgy9kqAbPt5rKaCr7uFtpo4mSUvLHIUbJEjm0vo+aY5QLwBUq3FK4tRnJr/X0Psc0C4jf/h+HtXSMw==
dependencies:
bluebird "^3.1.1"
constants-browserify@^1.0.0: constants-browserify@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75"
@@ -1766,22 +1804,24 @@ css-declaration-sorter@^4.0.1:
postcss "^7.0.1" postcss "^7.0.1"
timsort "^0.3.0" timsort "^0.3.0"
css-loader@~2.1.0: css-loader@^3.6.0:
version "2.1.1" version "3.6.0"
resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-2.1.1.tgz#d8254f72e412bb2238bb44dd674ffbef497333ea" resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-3.6.0.tgz#2e4b2c7e6e2d27f8c8f28f61bffcd2e6c91ef645"
integrity sha512-OcKJU/lt232vl1P9EEDamhoO9iKY3tIjY5GU+XDLblAykTdgs6Ux9P1hTHve8nFKy5KPpOXOsVI/hIwi3841+w== integrity sha512-M5lSukoWi1If8dhQAUCvj4H8vUt3vOnwbQBH9DdTm/s4Ym2B/3dPMtYZeJmq7Q3S3Pa+I94DcZ7pc9bP14cWIQ==
dependencies: dependencies:
camelcase "^5.2.0" camelcase "^5.3.1"
icss-utils "^4.1.0" cssesc "^3.0.0"
icss-utils "^4.1.1"
loader-utils "^1.2.3" loader-utils "^1.2.3"
normalize-path "^3.0.0" normalize-path "^3.0.0"
postcss "^7.0.14" postcss "^7.0.32"
postcss-modules-extract-imports "^2.0.0" postcss-modules-extract-imports "^2.0.0"
postcss-modules-local-by-default "^2.0.6" postcss-modules-local-by-default "^3.0.2"
postcss-modules-scope "^2.1.0" postcss-modules-scope "^2.2.0"
postcss-modules-values "^2.0.0" postcss-modules-values "^3.0.0"
postcss-value-parser "^3.3.0" postcss-value-parser "^4.1.0"
schema-utils "^1.0.0" schema-utils "^2.7.0"
semver "^6.3.0"
css-select-base-adapter@^0.1.1: css-select-base-adapter@^0.1.1:
version "0.1.1" version "0.1.1"
@@ -1938,6 +1978,11 @@ date-now@^0.1.4:
resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b" resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b"
integrity sha1-6vQ5/U1ISK105cx9vvIAZyueNFs= integrity sha1-6vQ5/U1ISK105cx9vvIAZyueNFs=
de-indent@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d"
integrity sha1-sgOOhG3DO6pXlhKNCAS0VbjB4h0=
debug@^2.2.0, debug@^2.3.3: debug@^2.2.0, debug@^2.3.3:
version "2.6.9" version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
@@ -2906,6 +2951,11 @@ hash-base@^3.0.0:
inherits "^2.0.1" inherits "^2.0.1"
safe-buffer "^5.0.1" safe-buffer "^5.0.1"
hash-sum@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/hash-sum/-/hash-sum-1.0.2.tgz#33b40777754c6432573c120cc3808bbd10d47f04"
integrity sha1-M7QHd3VMZDJXPBIMw4CLvRDUfwQ=
hash.js@^1.0.0, hash.js@^1.0.3: hash.js@^1.0.0, hash.js@^1.0.3:
version "1.1.7" version "1.1.7"
resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42" resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42"
@@ -2914,6 +2964,11 @@ hash.js@^1.0.0, hash.js@^1.0.3:
inherits "^2.0.3" inherits "^2.0.3"
minimalistic-assert "^1.0.1" minimalistic-assert "^1.0.1"
he@^1.1.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
hex-color-regex@^1.1.0: hex-color-regex@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e"
@@ -2992,12 +3047,7 @@ iconv-lite@^0.4.24, iconv-lite@^0.4.4:
dependencies: dependencies:
safer-buffer ">= 2.1.2 < 3" safer-buffer ">= 2.1.2 < 3"
icss-replace-symbols@^1.1.0: icss-utils@^4.0.0, icss-utils@^4.1.1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded"
integrity sha1-Bupvg2ead0njhs/h/oEq5dsiPe0=
icss-utils@^4.1.0:
version "4.1.1" version "4.1.1"
resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-4.1.1.tgz#21170b53789ee27447c2f47dd683081403f9a467" resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-4.1.1.tgz#21170b53789ee27447c2f47dd683081403f9a467"
integrity sha512-4aFq7wvWyMHKgxsH8QQtGpvbASCf+eM3wPRLI6R+MgAnTCZ6STYsRvttLvRWK0Nfif5piF394St3HeJDaljGPA== integrity sha512-4aFq7wvWyMHKgxsH8QQtGpvbASCf+eM3wPRLI6R+MgAnTCZ6STYsRvttLvRWK0Nfif5piF394St3HeJDaljGPA==
@@ -3711,7 +3761,7 @@ lowercase-keys@^1.0.0:
resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f" resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f"
integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA== integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==
lru-cache@^4.0.1, lru-cache@^4.1.5: lru-cache@^4.0.1, lru-cache@^4.1.2, lru-cache@^4.1.5:
version "4.1.5" version "4.1.5"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd"
integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g== integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==
@@ -3833,6 +3883,13 @@ meow@^3.7.0:
redent "^1.0.0" redent "^1.0.0"
trim-newlines "^1.0.0" trim-newlines "^1.0.0"
merge-source-map@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/merge-source-map/-/merge-source-map-1.1.0.tgz#2fdde7e6020939f70906a68f2d7ae685e4c8c646"
integrity sha512-Qkcp7P2ygktpMPh2mCQZaf3jhN6D3Z/qVZHSdWvQ+2Ef5HgRAPBO57A77+ENm0CPx2+1Ce/MYKi3ymqdfuqibw==
dependencies:
source-map "^0.6.1"
micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4, micromatch@^3.1.8: micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4, micromatch@^3.1.8:
version "3.1.10" version "3.1.10"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23"
@@ -4753,29 +4810,30 @@ postcss-modules-extract-imports@^2.0.0:
dependencies: dependencies:
postcss "^7.0.5" postcss "^7.0.5"
postcss-modules-local-by-default@^2.0.6: postcss-modules-local-by-default@^3.0.2:
version "2.0.6" version "3.0.2"
resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-2.0.6.tgz#dd9953f6dd476b5fd1ef2d8830c8929760b56e63" resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-3.0.2.tgz#e8a6561be914aaf3c052876377524ca90dbb7915"
integrity sha512-oLUV5YNkeIBa0yQl7EYnxMgy4N6noxmiwZStaEJUSe2xPMcdNc8WmBQuQCx18H5psYbVxz8zoHk0RAAYZXP9gA== integrity sha512-jM/V8eqM4oJ/22j0gx4jrp63GSvDH6v86OqyTHHUvk4/k1vceipZsaymiZ5PvocqZOl5SFHiFJqjs3la0wnfIQ==
dependencies: dependencies:
postcss "^7.0.6" icss-utils "^4.1.1"
postcss-selector-parser "^6.0.0" postcss "^7.0.16"
postcss-value-parser "^3.3.1" postcss-selector-parser "^6.0.2"
postcss-value-parser "^4.0.0"
postcss-modules-scope@^2.1.0: postcss-modules-scope@^2.2.0:
version "2.1.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-2.1.0.tgz#ad3f5bf7856114f6fcab901b0502e2a2bc39d4eb" resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-2.2.0.tgz#385cae013cc7743f5a7d7602d1073a89eaae62ee"
integrity sha512-91Rjps0JnmtUB0cujlc8KIKCsJXWjzuxGeT/+Q2i2HXKZ7nBUeF9YQTZZTNvHVoNYj1AthsjnGLtqDUE0Op79A== integrity sha512-YyEgsTMRpNd+HmyC7H/mh3y+MeFWevy7V1evVhJWewmMbjDHIbZbOXICC2y+m1xI1UVfIT1HMW/O04Hxyu9oXQ==
dependencies: dependencies:
postcss "^7.0.6" postcss "^7.0.6"
postcss-selector-parser "^6.0.0" postcss-selector-parser "^6.0.0"
postcss-modules-values@^2.0.0: postcss-modules-values@^3.0.0:
version "2.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-2.0.0.tgz#479b46dc0c5ca3dc7fa5270851836b9ec7152f64" resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-3.0.0.tgz#5b5000d6ebae29b4255301b4a3a54574423e7f10"
integrity sha512-Ki7JZa7ff1N3EIMlPnGTZfUMe69FFwiQPnVSXC9mnn3jozCRBYIxiZd44yJOV2AmabOo4qFf8s0dC/+lweG7+w== integrity sha512-1//E5jCBrZ9DmRX+zCtmQtRSV6PV42Ix7Bzj9GbwJceduuf7IqP8MgeTXuRDHOWj2m0VzZD5+roFWDuU8RQjcg==
dependencies: dependencies:
icss-replace-symbols "^1.1.0" icss-utils "^4.0.0"
postcss "^7.0.6" postcss "^7.0.6"
postcss-normalize-charset@^4.0.1: postcss-normalize-charset@^4.0.1:
@@ -4906,7 +4964,7 @@ postcss-selector-parser@^5.0.0-rc.4:
indexes-of "^1.0.1" indexes-of "^1.0.1"
uniq "^1.0.1" uniq "^1.0.1"
postcss-selector-parser@^6.0.0: postcss-selector-parser@^6.0.0, postcss-selector-parser@^6.0.2:
version "6.0.2" version "6.0.2"
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.2.tgz#934cf799d016c83411859e09dcecade01286ec5c" resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.2.tgz#934cf799d016c83411859e09dcecade01286ec5c"
integrity sha512-36P2QR59jDTOAiIkqEprfJDsoNrvwFei3eCqKd1Y0tUsBimsq39BLp7RD+JWny3WgB1zGhJX8XVePwm9k4wdBg== integrity sha512-36P2QR59jDTOAiIkqEprfJDsoNrvwFei3eCqKd1Y0tUsBimsq39BLp7RD+JWny3WgB1zGhJX8XVePwm9k4wdBg==
@@ -4934,11 +4992,16 @@ postcss-unique-selectors@^4.0.1:
postcss "^7.0.0" postcss "^7.0.0"
uniqs "^2.0.0" uniqs "^2.0.0"
postcss-value-parser@^3.0.0, postcss-value-parser@^3.3.0, postcss-value-parser@^3.3.1: postcss-value-parser@^3.0.0, postcss-value-parser@^3.3.1:
version "3.3.1" version "3.3.1"
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281" resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281"
integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ== integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==
postcss-value-parser@^4.0.0, postcss-value-parser@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz#443f6a20ced6481a2bda4fa8532a6e55d789a2cb"
integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==
postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.5, postcss@^7.0.6: postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.5, postcss@^7.0.6:
version "7.0.17" version "7.0.17"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.17.tgz#4da1bdff5322d4a0acaab4d87f3e782436bad31f" resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.17.tgz#4da1bdff5322d4a0acaab4d87f3e782436bad31f"
@@ -4948,6 +5011,15 @@ postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.5, postcss@^7.0.6:
source-map "^0.6.1" source-map "^0.6.1"
supports-color "^6.1.0" supports-color "^6.1.0"
postcss@^7.0.16, postcss@^7.0.32:
version "7.0.32"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.32.tgz#4310d6ee347053da3433db2be492883d62cec59d"
integrity sha512-03eXong5NLnNCD05xscnGKGDZ98CyzoqPSMjOe6SuoQY7Z2hIj0Ld1g/O/UQRuOle2aRtiIRDg9tDcTGAkLfKw==
dependencies:
chalk "^2.4.2"
source-map "^0.6.1"
supports-color "^6.1.0"
prelude-ls@~1.1.2: prelude-ls@~1.1.2:
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
@@ -4958,6 +5030,11 @@ prepend-http@^1.0.0, prepend-http@^1.0.1:
resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc"
integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw= integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=
prettier@^1.18.2:
version "1.19.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb"
integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==
private@^0.1.6: private@^0.1.6:
version "0.1.8" version "0.1.8"
resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff" resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff"
@@ -5505,6 +5582,15 @@ schema-utils@^2.1.0:
ajv "^6.1.0" ajv "^6.1.0"
ajv-keywords "^3.1.0" ajv-keywords "^3.1.0"
schema-utils@^2.7.0:
version "2.7.0"
resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.0.tgz#17151f76d8eae67fbbf77960c33c676ad9f4efc7"
integrity sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==
dependencies:
"@types/json-schema" "^7.0.4"
ajv "^6.12.2"
ajv-keywords "^3.4.1"
scss-tokenizer@^0.2.3: scss-tokenizer@^0.2.3:
version "0.2.3" version "0.2.3"
resolved "https://registry.yarnpkg.com/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz#8eb06db9a9723333824d3f5530641149847ce5d1" resolved "https://registry.yarnpkg.com/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz#8eb06db9a9723333824d3f5530641149847ce5d1"
@@ -6446,6 +6532,48 @@ vm-browserify@^1.0.1:
resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.0.tgz#bd76d6a23323e2ca8ffa12028dc04559c75f9019" resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.0.tgz#bd76d6a23323e2ca8ffa12028dc04559c75f9019"
integrity sha512-iq+S7vZJE60yejDYM0ek6zg308+UZsdtPExWP9VZoCFCz1zkJoXFnAX7aZfd/ZwrkidzdUZL0C/ryW+JwAiIGw== integrity sha512-iq+S7vZJE60yejDYM0ek6zg308+UZsdtPExWP9VZoCFCz1zkJoXFnAX7aZfd/ZwrkidzdUZL0C/ryW+JwAiIGw==
vue-hot-reload-api@^2.3.0:
version "2.3.4"
resolved "https://registry.yarnpkg.com/vue-hot-reload-api/-/vue-hot-reload-api-2.3.4.tgz#532955cc1eb208a3d990b3a9f9a70574657e08f2"
integrity sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==
vue-loader@15.9.3:
version "15.9.3"
resolved "https://registry.yarnpkg.com/vue-loader/-/vue-loader-15.9.3.tgz#0de35d9e555d3ed53969516cac5ce25531299dda"
integrity sha512-Y67VnGGgVLH5Voostx8JBZgPQTlDQeOVBLOEsjc2cXbCYBKexSKEpOA56x0YZofoDOTszrLnIShyOX1p9uCEHA==
dependencies:
"@vue/component-compiler-utils" "^3.1.0"
hash-sum "^1.0.2"
loader-utils "^1.1.0"
vue-hot-reload-api "^2.3.0"
vue-style-loader "^4.1.0"
vue-style-loader@^4.1.0, vue-style-loader@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/vue-style-loader/-/vue-style-loader-4.1.2.tgz#dedf349806f25ceb4e64f3ad7c0a44fba735fcf8"
integrity sha512-0ip8ge6Gzz/Bk0iHovU9XAUQaFt/G2B61bnWa2tCcqqdgfHs1lF9xXorFbE55Gmy92okFT+8bfmySuUOu13vxQ==
dependencies:
hash-sum "^1.0.2"
loader-utils "^1.0.2"
vue-template-compiler@^2.6.11:
version "2.6.11"
resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.6.11.tgz#c04704ef8f498b153130018993e56309d4698080"
integrity sha512-KIq15bvQDrcCjpGjrAhx4mUlyyHfdmTaoNfeoATHLAiWB+MU3cx4lOzMwrnUh9cCxy0Lt1T11hAFY6TQgroUAA==
dependencies:
de-indent "^1.0.2"
he "^1.1.0"
vue-template-es2015-compiler@^1.9.0:
version "1.9.1"
resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825"
integrity sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==
vue@^2.6.11:
version "2.6.11"
resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.11.tgz#76594d877d4b12234406e84e35275c6d514125c5"
integrity sha512-VfPwgcGABbGAue9+sfrD4PuwFar7gPb1yl1UK1MwXoQPAw0BKSqWfoYCT/ThFrdEVWoI51dBuyCoiNU9bZDZxQ==
watchpack@^1.5.0: watchpack@^1.5.0:
version "1.6.0" version "1.6.0"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.0.tgz#4bc12c2ebe8aa277a71f1d3f14d685c7b446cd00" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.0.tgz#4bc12c2ebe8aa277a71f1d3f14d685c7b446cd00"