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**:
- CTFd Version/Commit:
- Operating System:
- Web Browser and Version:
- CTFd Version/Commit:
- Operating System:
- Web Browser and Version:
**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**
**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?**
* **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?**
* 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?**

View File

@@ -27,7 +27,7 @@ def pages_preview():
data = request.form.to_dict()
schema = PageSchema()
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>")

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.notifications import notifications_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.statistics import statistics_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")
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(tags_namespace, "/tags")
CTFd_API_v1.add_namespace(awards_namespace, "/awards")

View File

@@ -1,6 +1,10 @@
from typing import List
from flask import request
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.models import Awards, Users, db
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")
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("")
class AwardList(Resource):
@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):
req = request.get_json()
@@ -57,6 +90,16 @@ class AwardList(Resource):
@awards_namespace.param("award_id", "An Award ID")
class Award(Resource):
@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):
award = Awards.query.filter_by(id=award_id).first_or_404()
response = AwardSchema().dump(award)
@@ -66,6 +109,10 @@ class Award(Resource):
return {"success": True, "data": response.data}
@admins_only
@awards_namespace.doc(
description="Endpoint to delete an Award object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, award_id):
award = Awards.query.filter_by(id=award_id).first_or_404()
db.session.delete(award)

View File

@@ -1,12 +1,25 @@
import datetime
from typing import List
from flask import abort, render_template, request, url_for
from flask_restx import Namespace, Resource
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.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.schemas.flags import FlagSchema
from CTFd.schemas.hints import HintSchema
@@ -37,12 +50,42 @@ challenges_namespace = Namespace(
"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("")
class ChallengeList(Resource):
@check_challenge_visibility
@during_ctf_time_only
@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):
# This can return None (unauth) if visibility is set to public
user = get_current_user()
@@ -138,6 +181,16 @@ class ChallengeList(Resource):
}
@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):
data = request.form or request.get_json()
challenge_type = data["type"]
@@ -168,11 +221,20 @@ class ChallengeTypes(Resource):
@challenges_namespace.route("/<challenge_id>")
@challenges_namespace.param("challenge_id", "A Challenge ID")
class Challenge(Resource):
@check_challenge_visibility
@during_ctf_time_only
@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):
if is_admin():
chal = Challenges.query.filter(Challenges.id == challenge_id).first_or_404()
@@ -289,6 +351,15 @@ class Challenge(Resource):
else:
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["tags"] = tags
response["hints"] = hints
@@ -299,6 +370,8 @@ class Challenge(Resource):
files=files,
tags=tags,
hints=[Hints(**h) for h in hints],
max_attempts=chal.max_attempts,
attempts=attempts,
challenge=chal,
)
@@ -306,6 +379,16 @@ class Challenge(Resource):
return {"success": True, "data": response}
@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):
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
challenge_class = get_chal_class(challenge.type)
@@ -314,6 +397,10 @@ class Challenge(Resource):
return {"success": True, "data": response}
@admins_only
@challenges_namespace.doc(
description="Endpoint to delete a specific Challenge object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, challenge_id):
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
chal_class = get_chal_class(challenge.type)
@@ -524,7 +611,6 @@ class ChallengeAttempt(Resource):
@challenges_namespace.route("/<challenge_id>/solves")
@challenges_namespace.param("id", "A Challenge ID")
class ChallengeSolves(Resource):
@check_challenge_visibility
@check_score_visibility
@@ -572,7 +658,6 @@ class ChallengeSolves(Resource):
@challenges_namespace.route("/<challenge_id>/files")
@challenges_namespace.param("id", "A Challenge ID")
class ChallengeFiles(Resource):
@admins_only
def get(self, challenge_id):
@@ -588,7 +673,6 @@ class ChallengeFiles(Resource):
@challenges_namespace.route("/<challenge_id>/tags")
@challenges_namespace.param("id", "A Challenge ID")
class ChallengeTags(Resource):
@admins_only
def get(self, challenge_id):
@@ -604,7 +688,6 @@ class ChallengeTags(Resource):
@challenges_namespace.route("/<challenge_id>/hints")
@challenges_namespace.param("id", "A Challenge ID")
class ChallengeHints(Resource):
@admins_only
def get(self, challenge_id):
@@ -619,7 +702,6 @@ class ChallengeHints(Resource):
@challenges_namespace.route("/<challenge_id>/flags")
@challenges_namespace.param("id", "A Challenge ID")
class ChallengeFlags(Resource):
@admins_only
def get(self, challenge_id):

View File

@@ -1,6 +1,10 @@
from typing import List
from flask import request
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.models import Configs, db
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")
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("")
class ConfigList(Resource):
@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):
configs = Configs.query.all()
schema = ConfigSchema(many=True)
@@ -23,6 +56,16 @@ class ConfigList(Resource):
return {"success": True, "data": response.data}
@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):
req = request.get_json()
schema = ConfigSchema()
@@ -43,6 +86,10 @@ class ConfigList(Resource):
return {"success": True, "data": response.data}
@admins_only
@configs_namespace.doc(
description="Endpoint to get patch Config objects in bulk",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def patch(self):
req = request.get_json()
@@ -58,11 +105,13 @@ class ConfigList(Resource):
@configs_namespace.route("/<config_key>")
class Config(Resource):
@admins_only
# TODO: This returns weirdly structured data. It should more closely match ConfigDetailedSuccessResponse #1506
def get(self, config_key):
return {"success": True, "data": get_config(config_key)}
@admins_only
# TODO: This returns weirdly structured data. It should more closely match ConfigDetailedSuccessResponse #1506
def patch(self, config_key):
config = Configs.query.filter_by(key=config_key).first()
data = request.get_json()
@@ -89,6 +138,10 @@ class Config(Resource):
return {"success": True, "data": response.data}
@admins_only
@configs_namespace.doc(
description="Endpoint to delete a Config object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, config_key):
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_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.schemas.files import FileSchema
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")
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("")
class FilesList(Resource):
@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):
file_type = request.args.get("type")
files = Files.query.filter_by(type=file_type).paginate(max_per_page=100)
@@ -37,6 +70,16 @@ class FilesList(Resource):
}
@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):
files = request.files.getlist("file")
# challenge_id
@@ -60,6 +103,16 @@ class FilesList(Resource):
@files_namespace.route("/<file_id>")
class FilesDetail(Resource):
@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):
f = Files.query.filter_by(id=file_id).first_or_404()
schema = FileSchema()
@@ -71,6 +124,10 @@ class FilesDetail(Resource):
return {"success": True, "data": response.data}
@admins_only
@files_namespace.doc(
description="Endpoint to delete a file object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, file_id):
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_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.plugins.flags import FLAG_CLASSES, get_flag_class
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")
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("")
class FlagList(Resource):
@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):
flags = Flags.query.paginate(max_per_page=100)
schema = FlagSchema(many=True)
@@ -35,6 +68,16 @@ class FlagList(Resource):
}
@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):
req = request.get_json()
schema = FlagSchema()
@@ -75,6 +118,16 @@ class FlagTypes(Resource):
@flags_namespace.route("/<flag_id>")
class Flag(Resource):
@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):
flag = Flags.query.filter_by(id=flag_id).first_or_404()
schema = FlagSchema()
@@ -88,6 +141,10 @@ class Flag(Resource):
return {"success": True, "data": response.data}
@admins_only
@flags_namespace.doc(
description="Endpoint to delete a specific Flag object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, flag_id):
flag = Flags.query.filter_by(id=flag_id).first_or_404()
@@ -98,6 +155,16 @@ class Flag(Resource):
return {"success": True}
@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):
flag = Flags.query.filter_by(id=flag_id).first_or_404()
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_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.schemas.hints import HintSchema
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")
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("")
class HintList(Resource):
@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):
hints = Hints.query.paginate(max_per_page=100)
response = HintSchema(many=True).dump(hints.items)
@@ -35,6 +68,16 @@ class HintList(Resource):
}
@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):
req = request.get_json()
schema = HintSchema("admin")
@@ -55,6 +98,16 @@ class HintList(Resource):
class Hint(Resource):
@during_ctf_time_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):
user = get_current_user()
hint = Hints.query.filter_by(id=hint_id).first_or_404()
@@ -80,6 +133,16 @@ class Hint(Resource):
return {"success": True, "data": response.data}
@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):
hint = Hints.query.filter_by(id=hint_id).first_or_404()
req = request.get_json()
@@ -98,6 +161,10 @@ class Hint(Resource):
return {"success": True, "data": response.data}
@admins_only
@hints_namespace.doc(
description="Endpoint to delete a specific Tag object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, hint_id):
hint = Hints.query.filter_by(id=hint_id).first_or_404()
db.session.delete(hint)

View File

@@ -1,6 +1,10 @@
from typing import List
from flask import current_app, request
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.schemas.notifications import NotificationSchema
from CTFd.utils.decorators import admins_only
@@ -9,9 +13,39 @@ notifications_namespace = Namespace(
"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("")
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):
notifications = Notifications.query.paginate(max_per_page=100)
schema = NotificationSchema(many=True)
@@ -34,6 +68,16 @@ class NotificantionList(Resource):
}
@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):
req = request.get_json()
@@ -62,6 +106,16 @@ class NotificantionList(Resource):
@notifications_namespace.route("/<notification_id>")
@notifications_namespace.param("notification_id", "A Notification ID")
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):
notif = Notifications.query.filter_by(id=notification_id).first_or_404()
schema = NotificationSchema()
@@ -72,6 +126,10 @@ class Notification(Resource):
return {"success": True, "data": response.data}
@admins_only
@notifications_namespace.doc(
description="Endpoint to delete a notification object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, notification_id):
notif = Notifications.query.filter_by(id=notification_id).first_or_404()
db.session.delete(notif)

View File

@@ -1,6 +1,11 @@
from typing import List
from flask import request
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.models import Pages, db
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")
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.doc(
responses={200: "Success", 400: "An error occured processing your data"}
)
class PageList(Resource):
@admins_only
def get(self):
pages = Pages.query.paginate(max_per_page=100)
@pages_namespace.doc(
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)
response = schema.dump(pages.items)
if response.errors:
@@ -35,8 +85,19 @@ class PageList(Resource):
}
@admins_only
def post(self):
req = request.get_json()
@pages_namespace.doc(
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()
response = schema.load(req)
@@ -55,8 +116,19 @@ class PageList(Resource):
@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):
@admins_only
@pages_namespace.doc(description="Endpoint to read a page object")
def get(self, page_id):
page = Pages.query.filter_by(id=page_id).first_or_404()
schema = PageSchema()
@@ -68,6 +140,7 @@ class PageDetail(Resource):
return {"success": True, "data": response.data}
@admins_only
@pages_namespace.doc(description="Endpoint to edit a page object")
def patch(self, page_id):
page = Pages.query.filter_by(id=page_id).first_or_404()
req = request.get_json()
@@ -88,6 +161,10 @@ class PageDetail(Resource):
return {"success": True, "data": response.data}
@admins_only
@pages_namespace.doc(
description="Endpoint to delete a page object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, page_id):
page = Pages.query.filter_by(id=page_id).first_or_404()
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_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.models import Submissions, db
from CTFd.schemas.submissions import SubmissionSchema
@@ -10,10 +18,40 @@ submissions_namespace = Namespace(
"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("")
class SubmissionsList(Resource):
@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):
args = request.args.to_dict()
schema = SubmissionSchema(many=True)
@@ -51,8 +89,19 @@ class SubmissionsList(Resource):
}
@admins_only
def post(self):
req = request.get_json()
@submissions_namespace.doc(
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"))
schema = SubmissionSchema(instance=Model())
response = schema.load(req)
@@ -75,6 +124,16 @@ class SubmissionsList(Resource):
@submissions_namespace.param("submission_id", "A Submission ID")
class Submission(Resource):
@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):
submission = Submissions.query.filter_by(id=submission_id).first_or_404()
schema = SubmissionSchema()
@@ -86,6 +145,16 @@ class Submission(Resource):
return {"success": True, "data": response.data}
@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):
submission = Submissions.query.filter_by(id=submission_id).first_or_404()
db.session.delete(submission)

View File

@@ -1,16 +1,47 @@
from typing import List
from flask import request
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.schemas.tags import TagSchema
from CTFd.utils.decorators import admins_only
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("")
class TagList(Resource):
@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):
# TODO: Filter by challenge_id
tags = Tags.query.paginate(max_per_page=100)
@@ -36,6 +67,16 @@ class TagList(Resource):
}
@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):
req = request.get_json()
schema = TagSchema()
@@ -57,6 +98,16 @@ class TagList(Resource):
@tags_namespace.param("tag_id", "A Tag ID")
class Tag(Resource):
@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):
tag = Tags.query.filter_by(id=tag_id).first_or_404()
@@ -68,6 +119,16 @@ class Tag(Resource):
return {"success": True, "data": response.data}
@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):
tag = Tags.query.filter_by(id=tag_id).first_or_404()
schema = TagSchema()
@@ -85,6 +146,10 @@ class Tag(Resource):
return {"success": True, "data": response.data}
@admins_only
@tags_namespace.doc(
description="Endpoint to delete a specific Tag object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, tag_id):
tag = Tags.query.filter_by(id=tag_id).first_or_404()
db.session.delete(tag)

View File

@@ -1,8 +1,14 @@
import copy
from typing import List
from flask import abort, request, session
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.models import Awards, Submissions, Teams, Unlocks, Users, db
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")
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("")
class TeamList(Resource):
@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):
if is_admin() and request.args.get("view") == "admin":
teams = Teams.query.filter_by().paginate(per_page=50, max_per_page=100)
@@ -53,6 +89,16 @@ class TeamList(Resource):
}
@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):
req = request.get_json()
user_type = get_current_user_type()
@@ -78,6 +124,16 @@ class TeamList(Resource):
@teams_namespace.param("team_id", "Team ID")
class TeamPublic(Resource):
@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):
team = Teams.query.filter_by(id=team_id).first_or_404()
@@ -97,6 +153,16 @@ class TeamPublic(Resource):
return {"success": True, "data": response.data}
@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):
team = Teams.query.filter_by(id=team_id).first_or_404()
data = request.get_json()
@@ -119,6 +185,10 @@ class TeamPublic(Resource):
return {"success": True, "data": response.data}
@admins_only
@teams_namespace.doc(
description="Endpoint to delete a specific Team object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, team_id):
team = Teams.query.filter_by(id=team_id).first_or_404()
team_id = team.id
@@ -143,6 +213,16 @@ class TeamPublic(Resource):
class TeamPrivate(Resource):
@authed_only
@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):
team = get_current_team()
response = TeamSchema(view="self").dump(team)
@@ -156,6 +236,16 @@ class TeamPrivate(Resource):
@authed_only
@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):
team = get_current_team()
if team.captain_id != session["id"]:

View File

@@ -1,8 +1,11 @@
import datetime
from typing import List
from flask import request, session
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.schemas.tokens import TokenSchema
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")
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("")
class TokenList(Resource):
@require_verified_emails
@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):
user = get_current_user()
tokens = Tokens.query.filter_by(user_id=user.id).paginate(max_per_page=100)
@@ -43,6 +85,16 @@ class TokenList(Resource):
@require_verified_emails
@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):
req = request.get_json()
expiration = req.get("expiration")
@@ -67,6 +119,16 @@ class TokenList(Resource):
class TokenDetail(Resource):
@require_verified_emails
@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):
if is_admin():
token = Tokens.query.filter_by(id=token_id).first_or_404()
@@ -86,6 +148,10 @@ class TokenDetail(Resource):
@require_verified_emails
@authed_only
@tokens_namespace.doc(
description="Endpoint to delete an existing token object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, token_id):
if is_admin():
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_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.models import Unlocks, db, get_class_by_tablename
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")
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("")
class UnlockList(Resource):
@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):
unlocks = Unlocks.query.paginate(max_per_page=100)
schema = UnlockSchema()
@@ -45,6 +79,16 @@ class UnlockList(Resource):
@during_ctf_time_only
@require_verified_emails
@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):
req = request.get_json()
user = get_current_user()

View File

@@ -1,6 +1,13 @@
from typing import List
from flask import abort, request
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.models import (
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")
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("")
class UserList(Resource):
@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):
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:
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)
@@ -59,12 +97,21 @@ class UserList(Resource):
"data": response.data,
}
@users_namespace.doc()
@admins_only
@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={
"notify": "Whether to send the created user an email with their credentials"
}
},
)
@admins_only
def post(self):
req = request.get_json()
schema = UserSchema("admin")
@@ -94,6 +141,16 @@ class UserList(Resource):
@users_namespace.param("user_id", "User ID")
class UserPublic(Resource):
@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):
user = Users.query.filter_by(id=user_id).first_or_404()
@@ -112,6 +169,16 @@ class UserPublic(Resource):
return {"success": True, "data": response.data}
@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):
user = Users.query.filter_by(id=user_id).first_or_404()
data = request.get_json()
@@ -133,6 +200,10 @@ class UserPublic(Resource):
return {"success": True, "data": response}
@admins_only
@users_namespace.doc(
description="Endpoint to delete a specific User object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, user_id):
Notifications.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")
class UserPrivate(Resource):
@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):
user = get_current_user()
response = UserSchema("self").dump(user).data
@@ -161,6 +242,16 @@ class UserPrivate(Resource):
return {"success": True, "data": response}
@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):
user = get_current_user()
data = request.get_json()
@@ -309,6 +400,10 @@ class UserPublicAwards(Resource):
@users_namespace.param("user_id", "User ID")
class UserEmails(Resource):
@admins_only
@users_namespace.doc(
description="Endpoint to email a User object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
@ratelimit(method="POST", limit=10, interval=60)
def post(self, user_id):
req = request.get_json()
@@ -329,4 +424,4 @@ class UserEmails(Resource):
result, response = sendmail(addr=user.email, text=text)
return {"success": result, "data": {}}
return {"success": result}

View File

@@ -18,3 +18,32 @@ TeamAttrs = namedtuple(
"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",
],
)
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:
* Initial - The original point valuation
* Decay - The amount of solves before the challenge will be at the minimum
* Minimum - The lowest possible point valuation
- Initial - The original point valuation
- Decay - The amount of solves before the challenge will be at the minimum
- Minimum - The lowest possible point valuation
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**
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`
directory.
named `DynamicValueChallenge` so CTFd can serve the files in the `assets`
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 { showMediaLibrary } from "../styles";
import "core/utils";
import $ from "jquery";
import CTFd from "core/CTFd";
import { default as helpers } from "core/helpers";
import CodeMirror from "codemirror";
import "codemirror/mode/htmlmixed/htmlmixed.js";
import { ezQuery, 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);
}
import { ezToast } from "core/ezq";
function submit_form() {
// Save the CodeMirror data to the Textarea
@@ -196,12 +52,6 @@ function preview_page() {
$("#page-edit").submit();
}
function upload_media() {
helpers.files.upload($("#media-library-upload"), {}, function(_data) {
refresh_files();
});
}
$(() => {
window.editor = CodeMirror.fromTextArea(
document.getElementById("admin-pages-editor"),
@@ -213,55 +63,8 @@ $(() => {
}
);
$("#media-insert").click(function(_e) {
var tag = "";
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();
}
});
}
});
}
});
$("#media-button").click(function(_e) {
showMediaLibrary(window.editor);
});
$("#save-page").click(function(e) {
@@ -269,17 +72,6 @@ $(() => {
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();
});

View File

@@ -2,6 +2,33 @@ import "bootstrap/dist/js/bootstrap.bundle";
import { makeSortableTables } from "core/utils";
import $ from "jquery";
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() {
$("textarea.markdown").each(function(_i, e) {
@@ -19,6 +46,14 @@ export function bindMarkdownEditors() {
"|",
"link",
"image",
{
name: "media",
action: editor => {
showMediaLibrary(editor);
},
className: "fas fa-file-upload",
title: "Media Library"
},
"|",
"preview",
"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 %}
{% 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="row pt-5">
<div class="col-md-12">

View File

@@ -83,6 +83,16 @@
{% endfor %}
</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="col-md-9 form-group">
{% 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.dates import isoformat, unix_time, unix_time_millis
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.plugins import (
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_millis"] = unix_time_millis
app.jinja_env.filters["isoformat"] = isoformat
app.jinja_env.filters["pluralize"] = pluralize
def init_template_globals(app):
from CTFd.constants.config import Configs
from CTFd.constants.plugins import Plugins
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.utils.config.visibility import (
accounts_visible,
@@ -96,6 +100,8 @@ def init_template_globals(app):
app.jinja_env.globals.update(Plugins=Plugins)
app.jinja_env.globals.update(Session=Session)
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):

View File

@@ -3,11 +3,13 @@ lint:
yarn lint
black --check --exclude=CTFd/uploads --exclude=node_modules .
prettier --check 'CTFd/themes/**/assets/**/*'
prettier --check '**/*.md'
format:
isort --skip=CTFd/uploads -rc CTFd/ tests/
black --exclude=CTFd/uploads --exclude=node_modules .
prettier --write 'CTFd/themes/**/assets/**/*'
prettier --write '**/*.md'
test:
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)
[![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)
## 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 CTF in a can.](https://github.com/CTFd/CTFd/blob/master/CTFd/themes/core/static/img/scoreboard.png?raw=true)
## Features
* Create your own challenges, categories, hints, and flags from the Admin Interface
* Dynamic Scoring Challenges
* Unlockable challenge support
* Challenge plugin architecture to create your own custom challenges
* Static & Regex based flags
* Custom flag plugins
* Unlockable hints
* File uploads to the server or an Amazon S3-compatible backend
* Limit challenge attempts & hide challenges
* Automatic bruteforce protection
* Individual and Team based competitions
* Have users play on their own or form teams to play together
* Scoreboard with automatic tie resolution
* Hide Scores from the public
* Freeze Scores at a specific time
* Scoregraphs comparing the top 10 teams and team progress graphs
* Markdown content management system
* SMTP + Mailgun email support
* Email confirmation support
* Forgot password support
* Automatic competition starting and ending
* Team management, hiding, and banning
* Customize everything using the [plugin](https://github.com/CTFd/CTFd/wiki/Plugins) and [theme](https://github.com/CTFd/CTFd/tree/master/CTFd/themes) interfaces
* Importing and Exporting of CTF data for archival
* And a lot more...
- Create your own challenges, categories, hints, and flags from the Admin Interface
- Dynamic Scoring Challenges
- Unlockable challenge support
- Challenge plugin architecture to create your own custom challenges
- Static & Regex based flags
- Custom flag plugins
- Unlockable hints
- File uploads to the server or an Amazon S3-compatible backend
- Limit challenge attempts & hide challenges
- Automatic bruteforce protection
- Individual and Team based competitions
- Have users play on their own or form teams to play together
- Scoreboard with automatic tie resolution
- Hide Scores from the public
- Freeze Scores at a specific time
- Scoregraphs comparing the top 10 teams and team progress graphs
- Markdown content management system
- SMTP + Mailgun email support
- Email confirmation support
- Forgot password support
- Automatic competition starting and ending
- Team management, hiding, and banning
- Customize everything using the [plugin](https://github.com/CTFd/CTFd/wiki/Plugins) and [theme](https://github.com/CTFd/CTFd/tree/master/CTFd/themes) interfaces
- Importing and Exporting of CTF data for archival
- And a lot more...
## Install
1. Install dependencies: `pip install -r requirements.txt`
1. You can also use the `prepare.sh` script to install system dependencies using apt.
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.
1. Install dependencies: `pip install -r requirements.txt`
1. You can also use the `prepare.sh` script to install system dependencies using apt.
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:
@@ -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
## Live Demo
https://demo.ctfd.io/
## 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/)
If you prefer commercial support or have a special project, feel free to [contact us](https://ctfd.io/contact/).
## 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.
## 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.
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
* Logo by [Laura Barbera](http://www.laurabb.com/)
* Theme by [Christopher Thompson](https://github.com/breadchris)
* Notification Sound by [Terrence Martin](https://soundcloud.com/tj-martin-composer)
- Logo by [Laura Barbera](http://www.laurabb.com/)
- 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-multimodal": "~1.0.4",
"codemirror": "~5.42.2",
"css-loader": "~2.1.0",
"css-loader": "^3.6.0",
"easymde": "^2.10.1",
"echarts": "^4.8.0",
"eslint": "~5.12.0",
@@ -54,6 +54,10 @@
"typeface-lato": "~0.0.54",
"typeface-raleway": "~0.0.54",
"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-cli": "~3.2.1",
"webpack-fix-style-only-entries": "~0.3.0",

View File

@@ -22,6 +22,7 @@ flask-marshmallow==0.10.1
marshmallow-sqlalchemy==0.17.0
boto3==1.13.9
marshmallow==2.20.2
pydantic==1.5.1
lxml==4.5.1
html5lib==1.0.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 RemoveStrictPlugin = require('remove-strict-webpack-plugin')
const WebpackShellPlugin = require('webpack-shell-plugin');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const roots = {
'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: [
new VueLoaderPlugin(),
new webpack.NamedModulesPlugin(),
new RemoveStrictPlugin(),
// 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$/,
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"
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":
version "1.5.2"
resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8"
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":
version "1.7.11"
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"
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:
version "6.10.0"
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"
uri-js "^4.2.2"
ajv@^6.5.5:
ajv@^6.12.2, ajv@^6.5.5:
version "6.12.2"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.2.tgz#c629c5eced17baf314437918d2da88c99d5958cd"
integrity sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ==
@@ -1064,6 +1090,11 @@ block-stream@*:
dependencies:
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:
version "3.5.5"
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"
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"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
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"
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:
version "1.0.0"
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"
timsort "^0.3.0"
css-loader@~2.1.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-2.1.1.tgz#d8254f72e412bb2238bb44dd674ffbef497333ea"
integrity sha512-OcKJU/lt232vl1P9EEDamhoO9iKY3tIjY5GU+XDLblAykTdgs6Ux9P1hTHve8nFKy5KPpOXOsVI/hIwi3841+w==
css-loader@^3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-3.6.0.tgz#2e4b2c7e6e2d27f8c8f28f61bffcd2e6c91ef645"
integrity sha512-M5lSukoWi1If8dhQAUCvj4H8vUt3vOnwbQBH9DdTm/s4Ym2B/3dPMtYZeJmq7Q3S3Pa+I94DcZ7pc9bP14cWIQ==
dependencies:
camelcase "^5.2.0"
icss-utils "^4.1.0"
camelcase "^5.3.1"
cssesc "^3.0.0"
icss-utils "^4.1.1"
loader-utils "^1.2.3"
normalize-path "^3.0.0"
postcss "^7.0.14"
postcss "^7.0.32"
postcss-modules-extract-imports "^2.0.0"
postcss-modules-local-by-default "^2.0.6"
postcss-modules-scope "^2.1.0"
postcss-modules-values "^2.0.0"
postcss-value-parser "^3.3.0"
schema-utils "^1.0.0"
postcss-modules-local-by-default "^3.0.2"
postcss-modules-scope "^2.2.0"
postcss-modules-values "^3.0.0"
postcss-value-parser "^4.1.0"
schema-utils "^2.7.0"
semver "^6.3.0"
css-select-base-adapter@^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"
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:
version "2.6.9"
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"
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:
version "1.1.7"
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"
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:
version "1.1.0"
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:
safer-buffer ">= 2.1.2 < 3"
icss-replace-symbols@^1.1.0:
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:
icss-utils@^4.0.0, icss-utils@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-4.1.1.tgz#21170b53789ee27447c2f47dd683081403f9a467"
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"
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"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd"
integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==
@@ -3833,6 +3883,13 @@ meow@^3.7.0:
redent "^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:
version "3.1.10"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23"
@@ -4753,29 +4810,30 @@ postcss-modules-extract-imports@^2.0.0:
dependencies:
postcss "^7.0.5"
postcss-modules-local-by-default@^2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-2.0.6.tgz#dd9953f6dd476b5fd1ef2d8830c8929760b56e63"
integrity sha512-oLUV5YNkeIBa0yQl7EYnxMgy4N6noxmiwZStaEJUSe2xPMcdNc8WmBQuQCx18H5psYbVxz8zoHk0RAAYZXP9gA==
postcss-modules-local-by-default@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-3.0.2.tgz#e8a6561be914aaf3c052876377524ca90dbb7915"
integrity sha512-jM/V8eqM4oJ/22j0gx4jrp63GSvDH6v86OqyTHHUvk4/k1vceipZsaymiZ5PvocqZOl5SFHiFJqjs3la0wnfIQ==
dependencies:
postcss "^7.0.6"
postcss-selector-parser "^6.0.0"
postcss-value-parser "^3.3.1"
icss-utils "^4.1.1"
postcss "^7.0.16"
postcss-selector-parser "^6.0.2"
postcss-value-parser "^4.0.0"
postcss-modules-scope@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-2.1.0.tgz#ad3f5bf7856114f6fcab901b0502e2a2bc39d4eb"
integrity sha512-91Rjps0JnmtUB0cujlc8KIKCsJXWjzuxGeT/+Q2i2HXKZ7nBUeF9YQTZZTNvHVoNYj1AthsjnGLtqDUE0Op79A==
postcss-modules-scope@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-2.2.0.tgz#385cae013cc7743f5a7d7602d1073a89eaae62ee"
integrity sha512-YyEgsTMRpNd+HmyC7H/mh3y+MeFWevy7V1evVhJWewmMbjDHIbZbOXICC2y+m1xI1UVfIT1HMW/O04Hxyu9oXQ==
dependencies:
postcss "^7.0.6"
postcss-selector-parser "^6.0.0"
postcss-modules-values@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-2.0.0.tgz#479b46dc0c5ca3dc7fa5270851836b9ec7152f64"
integrity sha512-Ki7JZa7ff1N3EIMlPnGTZfUMe69FFwiQPnVSXC9mnn3jozCRBYIxiZd44yJOV2AmabOo4qFf8s0dC/+lweG7+w==
postcss-modules-values@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-3.0.0.tgz#5b5000d6ebae29b4255301b4a3a54574423e7f10"
integrity sha512-1//E5jCBrZ9DmRX+zCtmQtRSV6PV42Ix7Bzj9GbwJceduuf7IqP8MgeTXuRDHOWj2m0VzZD5+roFWDuU8RQjcg==
dependencies:
icss-replace-symbols "^1.1.0"
icss-utils "^4.0.0"
postcss "^7.0.6"
postcss-normalize-charset@^4.0.1:
@@ -4906,7 +4964,7 @@ postcss-selector-parser@^5.0.0-rc.4:
indexes-of "^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"
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.2.tgz#934cf799d016c83411859e09dcecade01286ec5c"
integrity sha512-36P2QR59jDTOAiIkqEprfJDsoNrvwFei3eCqKd1Y0tUsBimsq39BLp7RD+JWny3WgB1zGhJX8XVePwm9k4wdBg==
@@ -4934,11 +4992,16 @@ postcss-unique-selectors@^4.0.1:
postcss "^7.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"
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281"
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:
version "7.0.17"
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"
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:
version "1.1.2"
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"
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:
version "0.1.8"
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-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:
version "0.2.3"
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"
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:
version "1.6.0"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.0.tgz#4bc12c2ebe8aa277a71f1d3f14d685c7b446cd00"