mirror of
https://github.com/aljazceru/CTFd.git
synced 2025-12-17 05:54:19 +01:00
Challenge Topics (#1966)
* Closes #1897 * Adds Topics to Challenges where Topics are admin-only visible tags about challenges * Adds `/api/v1/topics` and `/api/v1/challenges/[challenge_id]/topics` to API * Challenge comments have been moved into a modal
This commit is contained in:
@@ -21,6 +21,7 @@ from CTFd.api.v1.submissions import submissions_namespace
|
||||
from CTFd.api.v1.tags import tags_namespace
|
||||
from CTFd.api.v1.teams import teams_namespace
|
||||
from CTFd.api.v1.tokens import tokens_namespace
|
||||
from CTFd.api.v1.topics import topics_namespace
|
||||
from CTFd.api.v1.unlocks import unlocks_namespace
|
||||
from CTFd.api.v1.users import users_namespace
|
||||
|
||||
@@ -35,6 +36,7 @@ CTFd_API_v1.schema_model("APISimpleSuccessResponse", APISimpleSuccessResponse.sc
|
||||
|
||||
CTFd_API_v1.add_namespace(challenges_namespace, "/challenges")
|
||||
CTFd_API_v1.add_namespace(tags_namespace, "/tags")
|
||||
CTFd_API_v1.add_namespace(topics_namespace, "/topics")
|
||||
CTFd_API_v1.add_namespace(awards_namespace, "/awards")
|
||||
CTFd_API_v1.add_namespace(hints_namespace, "/hints")
|
||||
CTFd_API_v1.add_namespace(flags_namespace, "/flags")
|
||||
|
||||
@@ -12,17 +12,9 @@ from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessRespon
|
||||
from CTFd.cache import clear_standings
|
||||
from CTFd.constants import RawEnum
|
||||
from CTFd.models import ChallengeFiles as ChallengeFilesModel
|
||||
from CTFd.models import (
|
||||
Challenges,
|
||||
Fails,
|
||||
Flags,
|
||||
Hints,
|
||||
HintUnlocks,
|
||||
Solves,
|
||||
Submissions,
|
||||
Tags,
|
||||
db,
|
||||
)
|
||||
from CTFd.models import Challenges
|
||||
from CTFd.models import ChallengeTopics as ChallengeTopicsModel
|
||||
from CTFd.models import Fails, Flags, Hints, HintUnlocks, Solves, Submissions, Tags, db
|
||||
from CTFd.plugins.challenges import CHALLENGE_CLASSES, get_chal_class
|
||||
from CTFd.schemas.challenges import ChallengeSchema
|
||||
from CTFd.schemas.flags import FlagSchema
|
||||
@@ -831,6 +823,26 @@ class ChallengeTags(Resource):
|
||||
return {"success": True, "data": response}
|
||||
|
||||
|
||||
@challenges_namespace.route("/<challenge_id>/topics")
|
||||
class ChallengeTopics(Resource):
|
||||
@admins_only
|
||||
def get(self, challenge_id):
|
||||
response = []
|
||||
|
||||
topics = ChallengeTopicsModel.query.filter_by(challenge_id=challenge_id).all()
|
||||
|
||||
for t in topics:
|
||||
response.append(
|
||||
{
|
||||
"id": t.id,
|
||||
"challenge_id": t.challenge_id,
|
||||
"topic_id": t.topic_id,
|
||||
"value": t.topic.value,
|
||||
}
|
||||
)
|
||||
return {"success": True, "data": response}
|
||||
|
||||
|
||||
@challenges_namespace.route("/<challenge_id>/hints")
|
||||
class ChallengeHints(Resource):
|
||||
@admins_only
|
||||
|
||||
177
CTFd/api/v1/topics.py
Normal file
177
CTFd/api/v1/topics.py
Normal file
@@ -0,0 +1,177 @@
|
||||
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.constants import RawEnum
|
||||
from CTFd.models import ChallengeTopics, Topics, db
|
||||
from CTFd.schemas.topics import ChallengeTopicSchema, TopicSchema
|
||||
from CTFd.utils.decorators import admins_only
|
||||
from CTFd.utils.helpers.models import build_model_filters
|
||||
|
||||
topics_namespace = Namespace("topics", description="Endpoint to retrieve Topics")
|
||||
|
||||
TopicModel = sqlalchemy_to_pydantic(Topics)
|
||||
|
||||
|
||||
class TopicDetailedSuccessResponse(APIDetailedSuccessResponse):
|
||||
data: TopicModel
|
||||
|
||||
|
||||
class TopicListSuccessResponse(APIListSuccessResponse):
|
||||
data: List[TopicModel]
|
||||
|
||||
|
||||
topics_namespace.schema_model(
|
||||
"TopicDetailedSuccessResponse", TopicDetailedSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
topics_namespace.schema_model(
|
||||
"TopicListSuccessResponse", TopicListSuccessResponse.apidoc()
|
||||
)
|
||||
|
||||
|
||||
@topics_namespace.route("")
|
||||
class TopicList(Resource):
|
||||
@admins_only
|
||||
@topics_namespace.doc(
|
||||
description="Endpoint to list Topic objects in bulk",
|
||||
responses={
|
||||
200: ("Success", "TopicListSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
@validate_args(
|
||||
{
|
||||
"value": (str, None),
|
||||
"q": (str, None),
|
||||
"field": (RawEnum("TopicFields", {"value": "value"}), None,),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def get(self, query_args):
|
||||
q = query_args.pop("q", None)
|
||||
field = str(query_args.pop("field", None))
|
||||
filters = build_model_filters(model=Topics, query=q, field=field)
|
||||
|
||||
topics = Topics.query.filter_by(**query_args).filter(*filters).all()
|
||||
schema = TopicSchema(many=True)
|
||||
response = schema.dump(topics)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@topics_namespace.doc(
|
||||
description="Endpoint to create a Topic object",
|
||||
responses={
|
||||
200: ("Success", "TopicDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def post(self):
|
||||
req = request.get_json()
|
||||
value = req.get("value")
|
||||
|
||||
if value:
|
||||
topic = Topics.query.filter_by(value=value).first()
|
||||
if topic is None:
|
||||
schema = TopicSchema()
|
||||
response = schema.load(req, session=db.session)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
topic = response.data
|
||||
db.session.add(topic)
|
||||
db.session.commit()
|
||||
else:
|
||||
topic_id = req.get("topic_id")
|
||||
topic = Topics.query.filter_by(id=topic_id).first_or_404()
|
||||
|
||||
req["topic_id"] = topic.id
|
||||
topic_type = req.get("type")
|
||||
if topic_type == "challenge":
|
||||
schema = ChallengeTopicSchema()
|
||||
response = schema.load(req, session=db.session)
|
||||
else:
|
||||
return {"success": False}, 400
|
||||
|
||||
db.session.add(response.data)
|
||||
db.session.commit()
|
||||
|
||||
response = schema.dump(response.data)
|
||||
db.session.close()
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@topics_namespace.doc(
|
||||
description="Endpoint to delete a specific Topic object of a specific type",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
@validate_args(
|
||||
{"type": (str, None), "target_id": (int, 0)}, location="query",
|
||||
)
|
||||
def delete(self, query_args):
|
||||
topic_type = query_args.get("type")
|
||||
target_id = int(query_args.get("target_id", 0))
|
||||
|
||||
if topic_type == "challenge":
|
||||
Model = ChallengeTopics
|
||||
else:
|
||||
return {"success": False}, 400
|
||||
|
||||
topic = Model.query.filter_by(id=target_id).first_or_404()
|
||||
db.session.delete(topic)
|
||||
db.session.commit()
|
||||
db.session.close()
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@topics_namespace.route("/<topic_id>")
|
||||
class Topic(Resource):
|
||||
@admins_only
|
||||
@topics_namespace.doc(
|
||||
description="Endpoint to get a specific Topic object",
|
||||
responses={
|
||||
200: ("Success", "TopicDetailedSuccessResponse"),
|
||||
400: (
|
||||
"An error occured processing the provided or stored data",
|
||||
"APISimpleErrorResponse",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, topic_id):
|
||||
topic = Topics.query.filter_by(id=topic_id).first_or_404()
|
||||
response = TopicSchema().dump(topic)
|
||||
|
||||
if response.errors:
|
||||
return {"success": False, "errors": response.errors}, 400
|
||||
|
||||
return {"success": True, "data": response.data}
|
||||
|
||||
@admins_only
|
||||
@topics_namespace.doc(
|
||||
description="Endpoint to delete a specific Topic object",
|
||||
responses={200: ("Success", "APISimpleSuccessResponse")},
|
||||
)
|
||||
def delete(self, topic_id):
|
||||
topic = Topics.query.filter_by(id=topic_id).first_or_404()
|
||||
db.session.delete(topic)
|
||||
db.session.commit()
|
||||
db.session.close()
|
||||
|
||||
return {"success": True}
|
||||
@@ -97,6 +97,7 @@ class Challenges(db.Model):
|
||||
hints = db.relationship("Hints", backref="challenge")
|
||||
flags = db.relationship("Flags", backref="challenge")
|
||||
comments = db.relationship("ChallengeComments", backref="challenge")
|
||||
topics = db.relationship("ChallengeTopics", backref="challenge")
|
||||
|
||||
class alt_defaultdict(defaultdict):
|
||||
"""
|
||||
@@ -222,6 +223,31 @@ class Tags(db.Model):
|
||||
super(Tags, self).__init__(**kwargs)
|
||||
|
||||
|
||||
class Topics(db.Model):
|
||||
__tablename__ = "topics"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
value = db.Column(db.String(255), unique=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(Topics, self).__init__(**kwargs)
|
||||
|
||||
|
||||
class ChallengeTopics(db.Model):
|
||||
__tablename__ = "challenge_topics"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
challenge_id = db.Column(
|
||||
db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE")
|
||||
)
|
||||
topic_id = db.Column(db.Integer, db.ForeignKey("topics.id", ondelete="CASCADE"))
|
||||
|
||||
topic = db.relationship(
|
||||
"Topics", foreign_keys="ChallengeTopics.topic_id", lazy="select"
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(ChallengeTopics, self).__init__(**kwargs)
|
||||
|
||||
|
||||
class Files(db.Model):
|
||||
__tablename__ = "files"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
38
CTFd/schemas/topics.py
Normal file
38
CTFd/schemas/topics.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from CTFd.models import ChallengeTopics, Topics, ma
|
||||
from CTFd.utils import string_types
|
||||
|
||||
|
||||
class TopicSchema(ma.ModelSchema):
|
||||
class Meta:
|
||||
model = Topics
|
||||
include_fk = True
|
||||
dump_only = ("id",)
|
||||
|
||||
views = {"admin": ["id", "value"]}
|
||||
|
||||
def __init__(self, view=None, *args, **kwargs):
|
||||
if view:
|
||||
if isinstance(view, string_types):
|
||||
kwargs["only"] = self.views[view]
|
||||
elif isinstance(view, list):
|
||||
kwargs["only"] = view
|
||||
|
||||
super(TopicSchema, self).__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class ChallengeTopicSchema(ma.ModelSchema):
|
||||
class Meta:
|
||||
model = ChallengeTopics
|
||||
include_fk = True
|
||||
dump_only = ("id",)
|
||||
|
||||
views = {"admin": ["id", "challenge_id", "topic_id"]}
|
||||
|
||||
def __init__(self, view=None, *args, **kwargs):
|
||||
if view:
|
||||
if isinstance(view, string_types):
|
||||
kwargs["only"] = self.views[view]
|
||||
elif isinstance(view, list):
|
||||
kwargs["only"] = view
|
||||
|
||||
super(ChallengeTopicSchema, self).__init__(*args, **kwargs)
|
||||
188
CTFd/themes/admin/assets/js/components/topics/TopicsList.vue
Normal file
188
CTFd/themes/admin/assets/js/components/topics/TopicsList.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<div class="col-md-12">
|
||||
<div id="challenge-topics" class="my-3">
|
||||
<h5 class="challenge-tag" v-for="topic in topics" :key="topic.id">
|
||||
<span class="mr-1">{{ topic.value }}</span>
|
||||
<a class="btn-fa delete-tag" @click="deleteTopic(topic.id)"> ×</a>
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
Topic
|
||||
<br />
|
||||
<small class="text-muted">Type topic and press Enter</small>
|
||||
</label>
|
||||
<input
|
||||
id="tags-add-input"
|
||||
maxlength="255"
|
||||
type="text"
|
||||
class="form-control"
|
||||
v-model="topicValue"
|
||||
@keyup.down="moveCursor('down')"
|
||||
@keyup.up="moveCursor('up')"
|
||||
@keyup.enter="addTopic()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<ul class="list-group">
|
||||
<li
|
||||
:class="{
|
||||
'list-group-item': true,
|
||||
active: idx + 1 === selectedResultIdx
|
||||
}"
|
||||
v-for="(topic, idx) in topicResults"
|
||||
:key="topic.id"
|
||||
@click="selectTopic(idx)"
|
||||
>
|
||||
{{ topic.value }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CTFd from "core/CTFd";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
challenge_id: Number
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
topics: [],
|
||||
topicValue: "",
|
||||
searchedTopic: "",
|
||||
topicResults: [],
|
||||
selectedResultIdx: 0,
|
||||
awaitingSearch: false
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
loadTopics: function() {
|
||||
CTFd.fetch(`/api/v1/challenges/${this.$props.challenge_id}/topics`, {
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
return response.json();
|
||||
})
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
this.topics = response.data;
|
||||
}
|
||||
});
|
||||
},
|
||||
searchTopics: function() {
|
||||
this.selectedResultIdx = 0;
|
||||
if (this.topicValue == "") {
|
||||
this.topicResults = [];
|
||||
return;
|
||||
}
|
||||
|
||||
CTFd.fetch(`/api/v1/topics?field=value&q=${this.topicValue}`, {
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
return response.json();
|
||||
})
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
this.topicResults = response.data.slice(0, 10);
|
||||
}
|
||||
});
|
||||
},
|
||||
addTopic: function() {
|
||||
let value;
|
||||
if (this.selectedResultIdx === 0) {
|
||||
value = this.topicValue;
|
||||
} else {
|
||||
let idx = this.selectedResultIdx - 1;
|
||||
let topic = this.topicResults[idx];
|
||||
value = topic.value;
|
||||
}
|
||||
const params = {
|
||||
value: value,
|
||||
challenge: this.$props.challenge_id,
|
||||
type: "challenge"
|
||||
};
|
||||
|
||||
CTFd.fetch("/api/v1/topics", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(params)
|
||||
})
|
||||
.then(response => {
|
||||
return response.json();
|
||||
})
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
this.topicValue = "";
|
||||
this.loadTopics();
|
||||
}
|
||||
});
|
||||
},
|
||||
deleteTopic: function(topic_id) {
|
||||
CTFd.fetch(`/api/v1/topics?type=challenge&target_id=${topic_id}`, {
|
||||
method: "DELETE"
|
||||
})
|
||||
.then(response => {
|
||||
return response.json();
|
||||
})
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
this.loadTopics();
|
||||
}
|
||||
});
|
||||
},
|
||||
moveCursor: function(dir) {
|
||||
switch (dir) {
|
||||
case "up":
|
||||
if (this.selectedResultIdx) {
|
||||
this.selectedResultIdx -= 1;
|
||||
}
|
||||
break;
|
||||
case "down":
|
||||
if (this.selectedResultIdx < this.topicResults.length) {
|
||||
this.selectedResultIdx += 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
selectTopic: function(idx) {
|
||||
if (idx === undefined) {
|
||||
idx = this.selectedResultIdx;
|
||||
}
|
||||
let topic = this.topicResults[idx];
|
||||
this.topicValue = topic.value;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
topicValue: function(val) {
|
||||
if (this.awaitingSearch === false) {
|
||||
// 1 second delay after typing
|
||||
setTimeout(() => {
|
||||
this.searchTopics();
|
||||
this.awaitingSearch = false;
|
||||
}, 500);
|
||||
}
|
||||
this.awaitingSearch = true;
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadTopics();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -11,6 +11,7 @@ import Vue from "vue/dist/vue.esm.browser";
|
||||
import CommentBox from "../components/comments/CommentBox.vue";
|
||||
import FlagList from "../components/flags/FlagList.vue";
|
||||
import Requirements from "../components/requirements/Requirements.vue";
|
||||
import TopicsList from "../components/topics/TopicsList.vue";
|
||||
import TagsList from "../components/tags/TagsList.vue";
|
||||
import ChallengeFilesList from "../components/files/ChallengeFilesList.vue";
|
||||
import HintsList from "../components/hints/HintsList.vue";
|
||||
@@ -325,6 +326,10 @@ $(() => {
|
||||
);
|
||||
});
|
||||
|
||||
$(".comments-challenge").click(function(_event) {
|
||||
$("#challenge-comments-window").modal();
|
||||
});
|
||||
|
||||
$(".delete-challenge").click(function(_e) {
|
||||
ezQuery({
|
||||
title: "Delete Challenge",
|
||||
@@ -438,6 +443,16 @@ $(() => {
|
||||
}).$mount(vueContainer);
|
||||
}
|
||||
|
||||
// Load TopicsList component
|
||||
if (document.querySelector("#challenge-topics")) {
|
||||
const topicsList = Vue.extend(TopicsList);
|
||||
let vueContainer = document.createElement("div");
|
||||
document.querySelector("#challenge-topics").appendChild(vueContainer);
|
||||
new topicsList({
|
||||
propsData: { challenge_id: window.CHALLENGE_ID }
|
||||
}).$mount(vueContainer);
|
||||
}
|
||||
|
||||
// Load TagsList component
|
||||
if (document.querySelector("#challenge-tags")) {
|
||||
const tagList = Vue.extend(TagsList);
|
||||
|
||||
@@ -564,6 +564,42 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _nod
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ "./CTFd/themes/admin/assets/js/components/topics/TopicsList.vue":
|
||||
/*!**********************************************************************!*\
|
||||
!*** ./CTFd/themes/admin/assets/js/components/topics/TopicsList.vue ***!
|
||||
\**********************************************************************/
|
||||
/*! no static exports found */
|
||||
/***/ (function(module, __webpack_exports__, __webpack_require__) {
|
||||
|
||||
;
|
||||
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _TopicsList_vue_vue_type_template_id_6982af81_scoped_true___WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./TopicsList.vue?vue&type=template&id=6982af81&scoped=true& */ \"./CTFd/themes/admin/assets/js/components/topics/TopicsList.vue?vue&type=template&id=6982af81&scoped=true&\");\n/* harmony import */ var _TopicsList_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./TopicsList.vue?vue&type=script&lang=js& */ \"./CTFd/themes/admin/assets/js/components/topics/TopicsList.vue?vue&type=script&lang=js&\");\n/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _TopicsList_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_1__) if(__WEBPACK_IMPORT_KEY__ !== 'default') (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _TopicsList_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_1__[key]; }) }(__WEBPACK_IMPORT_KEY__));\n/* harmony import */ var _node_modules_vue_loader_lib_runtime_componentNormalizer_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ../../../../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js */ \"./node_modules/vue-loader/lib/runtime/componentNormalizer.js\");\n\n\n\n\n\n/* normalize component */\n\nvar component = Object(_node_modules_vue_loader_lib_runtime_componentNormalizer_js__WEBPACK_IMPORTED_MODULE_2__[\"default\"])(\n _TopicsList_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_1__[\"default\"],\n _TopicsList_vue_vue_type_template_id_6982af81_scoped_true___WEBPACK_IMPORTED_MODULE_0__[\"render\"],\n _TopicsList_vue_vue_type_template_id_6982af81_scoped_true___WEBPACK_IMPORTED_MODULE_0__[\"staticRenderFns\"],\n false,\n null,\n \"6982af81\",\n null\n \n)\n\n/* hot reload */\nif (false) { var api; }\ncomponent.options.__file = \"CTFd/themes/admin/assets/js/components/topics/TopicsList.vue\"\n/* harmony default export */ __webpack_exports__[\"default\"] = (component.exports);\n\n//# sourceURL=webpack:///./CTFd/themes/admin/assets/js/components/topics/TopicsList.vue?");
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ "./CTFd/themes/admin/assets/js/components/topics/TopicsList.vue?vue&type=script&lang=js&":
|
||||
/*!***********************************************************************************************!*\
|
||||
!*** ./CTFd/themes/admin/assets/js/components/topics/TopicsList.vue?vue&type=script&lang=js& ***!
|
||||
\***********************************************************************************************/
|
||||
/*! no static exports found */
|
||||
/***/ (function(module, __webpack_exports__, __webpack_require__) {
|
||||
|
||||
;
|
||||
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _node_modules_babel_loader_lib_index_js_ref_0_node_modules_vue_loader_lib_index_js_vue_loader_options_TopicsList_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! -!../../../../../../../node_modules/babel-loader/lib??ref--0!../../../../../../../node_modules/vue-loader/lib??vue-loader-options!./TopicsList.vue?vue&type=script&lang=js& */ \"./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/topics/TopicsList.vue?vue&type=script&lang=js&\");\n/* harmony import */ var _node_modules_babel_loader_lib_index_js_ref_0_node_modules_vue_loader_lib_index_js_vue_loader_options_TopicsList_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_node_modules_babel_loader_lib_index_js_ref_0_node_modules_vue_loader_lib_index_js_vue_loader_options_TopicsList_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_0__);\n/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _node_modules_babel_loader_lib_index_js_ref_0_node_modules_vue_loader_lib_index_js_vue_loader_options_TopicsList_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_0__) if(__WEBPACK_IMPORT_KEY__ !== 'default') (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _node_modules_babel_loader_lib_index_js_ref_0_node_modules_vue_loader_lib_index_js_vue_loader_options_TopicsList_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_0__[key]; }) }(__WEBPACK_IMPORT_KEY__));\n /* harmony default export */ __webpack_exports__[\"default\"] = (_node_modules_babel_loader_lib_index_js_ref_0_node_modules_vue_loader_lib_index_js_vue_loader_options_TopicsList_vue_vue_type_script_lang_js___WEBPACK_IMPORTED_MODULE_0___default.a); \n\n//# sourceURL=webpack:///./CTFd/themes/admin/assets/js/components/topics/TopicsList.vue?");
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ "./CTFd/themes/admin/assets/js/components/topics/TopicsList.vue?vue&type=template&id=6982af81&scoped=true&":
|
||||
/*!*****************************************************************************************************************!*\
|
||||
!*** ./CTFd/themes/admin/assets/js/components/topics/TopicsList.vue?vue&type=template&id=6982af81&scoped=true& ***!
|
||||
\*****************************************************************************************************************/
|
||||
/*! exports provided: render, staticRenderFns */
|
||||
/***/ (function(module, __webpack_exports__, __webpack_require__) {
|
||||
|
||||
;
|
||||
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _node_modules_vue_loader_lib_loaders_templateLoader_js_vue_loader_options_node_modules_vue_loader_lib_index_js_vue_loader_options_TopicsList_vue_vue_type_template_id_6982af81_scoped_true___WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! -!../../../../../../../node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!../../../../../../../node_modules/vue-loader/lib??vue-loader-options!./TopicsList.vue?vue&type=template&id=6982af81&scoped=true& */ \"./node_modules/vue-loader/lib/loaders/templateLoader.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/topics/TopicsList.vue?vue&type=template&id=6982af81&scoped=true&\");\n/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, \"render\", function() { return _node_modules_vue_loader_lib_loaders_templateLoader_js_vue_loader_options_node_modules_vue_loader_lib_index_js_vue_loader_options_TopicsList_vue_vue_type_template_id_6982af81_scoped_true___WEBPACK_IMPORTED_MODULE_0__[\"render\"]; });\n\n/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, \"staticRenderFns\", function() { return _node_modules_vue_loader_lib_loaders_templateLoader_js_vue_loader_options_node_modules_vue_loader_lib_index_js_vue_loader_options_TopicsList_vue_vue_type_template_id_6982af81_scoped_true___WEBPACK_IMPORTED_MODULE_0__[\"staticRenderFns\"]; });\n\n\n\n//# sourceURL=webpack:///./CTFd/themes/admin/assets/js/components/topics/TopicsList.vue?");
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ "./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/comments/CommentBox.vue?vue&type=script&lang=js&":
|
||||
/*!*******************************************************************************************************************************************************************************************!*\
|
||||
!*** ./node_modules/babel-loader/lib??ref--0!./node_modules/vue-loader/lib??vue-loader-options!./CTFd/themes/admin/assets/js/components/comments/CommentBox.vue?vue&type=script&lang=js& ***!
|
||||
@@ -744,6 +780,18 @@ eval("\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\n
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ "./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/topics/TopicsList.vue?vue&type=script&lang=js&":
|
||||
/*!*****************************************************************************************************************************************************************************************!*\
|
||||
!*** ./node_modules/babel-loader/lib??ref--0!./node_modules/vue-loader/lib??vue-loader-options!./CTFd/themes/admin/assets/js/components/topics/TopicsList.vue?vue&type=script&lang=js& ***!
|
||||
\*****************************************************************************************************************************************************************************************/
|
||||
/*! no static exports found */
|
||||
/***/ (function(module, exports, __webpack_require__) {
|
||||
|
||||
;
|
||||
eval("\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports[\"default\"] = void 0;\n\nvar _CTFd = _interopRequireDefault(__webpack_require__(/*! core/CTFd */ \"./CTFd/themes/core/assets/js/CTFd.js\"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { \"default\": obj }; }\n\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\nvar _default = {\n props: {\n challenge_id: Number\n },\n data: function data() {\n return {\n topics: [],\n topicValue: \"\",\n searchedTopic: \"\",\n topicResults: [],\n selectedResultIdx: 0,\n awaitingSearch: false\n };\n },\n methods: {\n loadTopics: function loadTopics() {\n var _this = this;\n\n _CTFd[\"default\"].fetch(\"/api/v1/challenges/\".concat(this.$props.challenge_id, \"/topics\"), {\n method: \"GET\",\n credentials: \"same-origin\",\n headers: {\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\"\n }\n }).then(function (response) {\n return response.json();\n }).then(function (response) {\n if (response.success) {\n _this.topics = response.data;\n }\n });\n },\n searchTopics: function searchTopics() {\n var _this2 = this;\n\n this.selectedResultIdx = 0;\n\n if (this.topicValue == \"\") {\n this.topicResults = [];\n return;\n }\n\n _CTFd[\"default\"].fetch(\"/api/v1/topics?field=value&q=\".concat(this.topicValue), {\n method: \"GET\",\n credentials: \"same-origin\",\n headers: {\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\"\n }\n }).then(function (response) {\n return response.json();\n }).then(function (response) {\n if (response.success) {\n _this2.topicResults = response.data.slice(0, 10);\n }\n });\n },\n addTopic: function addTopic() {\n var _this3 = this;\n\n var value;\n\n if (this.selectedResultIdx === 0) {\n value = this.topicValue;\n } else {\n var idx = this.selectedResultIdx - 1;\n var topic = this.topicResults[idx];\n value = topic.value;\n }\n\n var params = {\n value: value,\n challenge: this.$props.challenge_id,\n type: \"challenge\"\n };\n\n _CTFd[\"default\"].fetch(\"/api/v1/topics\", {\n method: \"POST\",\n body: JSON.stringify(params)\n }).then(function (response) {\n return response.json();\n }).then(function (response) {\n if (response.success) {\n _this3.topicValue = \"\";\n\n _this3.loadTopics();\n }\n });\n },\n deleteTopic: function deleteTopic(topic_id) {\n var _this4 = this;\n\n _CTFd[\"default\"].fetch(\"/api/v1/topics?type=challenge&target_id=\".concat(topic_id), {\n method: \"DELETE\"\n }).then(function (response) {\n return response.json();\n }).then(function (response) {\n if (response.success) {\n _this4.loadTopics();\n }\n });\n },\n moveCursor: function moveCursor(dir) {\n switch (dir) {\n case \"up\":\n if (this.selectedResultIdx) {\n this.selectedResultIdx -= 1;\n }\n\n break;\n\n case \"down\":\n if (this.selectedResultIdx < this.topicResults.length) {\n this.selectedResultIdx += 1;\n }\n\n break;\n }\n },\n selectTopic: function selectTopic(idx) {\n if (idx === undefined) {\n idx = this.selectedResultIdx;\n }\n\n var topic = this.topicResults[idx];\n this.topicValue = topic.value;\n }\n },\n watch: {\n topicValue: function topicValue(val) {\n var _this5 = this;\n\n if (this.awaitingSearch === false) {\n // 1 second delay after typing\n setTimeout(function () {\n _this5.searchTopics();\n\n _this5.awaitingSearch = false;\n }, 500);\n }\n\n this.awaitingSearch = true;\n }\n },\n created: function created() {\n this.loadTopics();\n }\n};\nexports[\"default\"] = _default;\n\n//# sourceURL=webpack:///./CTFd/themes/admin/assets/js/components/topics/TopicsList.vue?./node_modules/babel-loader/lib??ref--0!./node_modules/vue-loader/lib??vue-loader-options");
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ "./node_modules/css-loader/dist/cjs.js!./node_modules/vue-loader/lib/loaders/stylePostLoader.js!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/comments/CommentBox.vue?vue&type=style&index=0&id=1fd2c08a&scoped=true&lang=css&":
|
||||
/*!**********************************************************************************************************************************************************************************************************************************************************************************!*\
|
||||
!*** ./node_modules/css-loader/dist/cjs.js!./node_modules/vue-loader/lib/loaders/stylePostLoader.js!./node_modules/vue-loader/lib??vue-loader-options!./CTFd/themes/admin/assets/js/components/comments/CommentBox.vue?vue&type=style&index=0&id=1fd2c08a&scoped=true&lang=css& ***!
|
||||
@@ -946,6 +994,18 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) *
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ "./node_modules/vue-loader/lib/loaders/templateLoader.js?!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/topics/TopicsList.vue?vue&type=template&id=6982af81&scoped=true&":
|
||||
/*!***********************************************************************************************************************************************************************************************************************************************!*\
|
||||
!*** ./node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!./node_modules/vue-loader/lib??vue-loader-options!./CTFd/themes/admin/assets/js/components/topics/TopicsList.vue?vue&type=template&id=6982af81&scoped=true& ***!
|
||||
\***********************************************************************************************************************************************************************************************************************************************/
|
||||
/*! exports provided: render, staticRenderFns */
|
||||
/***/ (function(module, __webpack_exports__, __webpack_require__) {
|
||||
|
||||
;
|
||||
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"render\", function() { return render; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"staticRenderFns\", function() { return staticRenderFns; });\nvar render = function() {\n var _vm = this\n var _h = _vm.$createElement\n var _c = _vm._self._c || _h\n return _c(\"div\", { staticClass: \"col-md-12\" }, [\n _c(\n \"div\",\n { staticClass: \"my-3\", attrs: { id: \"challenge-topics\" } },\n _vm._l(_vm.topics, function(topic) {\n return _c(\"h5\", { key: topic.id, staticClass: \"challenge-tag\" }, [\n _c(\"span\", { staticClass: \"mr-1\" }, [_vm._v(_vm._s(topic.value))]),\n _vm._v(\" \"),\n _c(\n \"a\",\n {\n staticClass: \"btn-fa delete-tag\",\n on: {\n click: function($event) {\n return _vm.deleteTopic(topic.id)\n }\n }\n },\n [_vm._v(\" ×\")]\n )\n ])\n }),\n 0\n ),\n _vm._v(\" \"),\n _c(\"div\", { staticClass: \"form-group\" }, [\n _vm._m(0),\n _vm._v(\" \"),\n _c(\"input\", {\n directives: [\n {\n name: \"model\",\n rawName: \"v-model\",\n value: _vm.topicValue,\n expression: \"topicValue\"\n }\n ],\n staticClass: \"form-control\",\n attrs: { id: \"tags-add-input\", maxlength: \"255\", type: \"text\" },\n domProps: { value: _vm.topicValue },\n on: {\n keyup: [\n function($event) {\n if (\n !$event.type.indexOf(\"key\") &&\n _vm._k($event.keyCode, \"down\", 40, $event.key, [\n \"Down\",\n \"ArrowDown\"\n ])\n ) {\n return null\n }\n return _vm.moveCursor(\"down\")\n },\n function($event) {\n if (\n !$event.type.indexOf(\"key\") &&\n _vm._k($event.keyCode, \"up\", 38, $event.key, [\"Up\", \"ArrowUp\"])\n ) {\n return null\n }\n return _vm.moveCursor(\"up\")\n },\n function($event) {\n if (\n !$event.type.indexOf(\"key\") &&\n _vm._k($event.keyCode, \"enter\", 13, $event.key, \"Enter\")\n ) {\n return null\n }\n return _vm.addTopic()\n }\n ],\n input: function($event) {\n if ($event.target.composing) {\n return\n }\n _vm.topicValue = $event.target.value\n }\n }\n })\n ]),\n _vm._v(\" \"),\n _c(\"div\", { staticClass: \"form-group\" }, [\n _c(\n \"ul\",\n { staticClass: \"list-group\" },\n _vm._l(_vm.topicResults, function(topic, idx) {\n return _c(\n \"li\",\n {\n key: topic.id,\n class: {\n \"list-group-item\": true,\n active: idx + 1 === _vm.selectedResultIdx\n },\n on: {\n click: function($event) {\n return _vm.selectTopic(idx)\n }\n }\n },\n [_vm._v(\"\\n \" + _vm._s(topic.value) + \"\\n \")]\n )\n }),\n 0\n )\n ])\n ])\n}\nvar staticRenderFns = [\n function() {\n var _vm = this\n var _h = _vm.$createElement\n var _c = _vm._self._c || _h\n return _c(\"label\", [\n _vm._v(\"\\n Topic\\n \"),\n _c(\"br\"),\n _vm._v(\" \"),\n _c(\"small\", { staticClass: \"text-muted\" }, [\n _vm._v(\"Type topic and press Enter\")\n ])\n ])\n }\n]\nrender._withStripped = true\n\n\n\n//# sourceURL=webpack:///./CTFd/themes/admin/assets/js/components/topics/TopicsList.vue?./node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!./node_modules/vue-loader/lib??vue-loader-options");
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ "./node_modules/vue-style-loader/index.js!./node_modules/css-loader/dist/cjs.js!./node_modules/vue-loader/lib/loaders/stylePostLoader.js!./node_modules/vue-loader/lib/index.js?!./CTFd/themes/admin/assets/js/components/comments/CommentBox.vue?vue&type=style&index=0&id=1fd2c08a&scoped=true&lang=css&":
|
||||
/*!******************************************************************************************************************************************************************************************************************************************************************************************************************!*\
|
||||
!*** ./node_modules/vue-style-loader!./node_modules/css-loader/dist/cjs.js!./node_modules/vue-loader/lib/loaders/stylePostLoader.js!./node_modules/vue-loader/lib??vue-loader-options!./CTFd/themes/admin/assets/js/components/comments/CommentBox.vue?vue&type=style&index=0&id=1fd2c08a&scoped=true&lang=css& ***!
|
||||
|
||||
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
@@ -8,6 +8,27 @@
|
||||
<div class="modal fade" id="challenge-window" role="dialog">
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="challenge-comments-window" role="dialog">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-action text-center w-100">Comments</h2>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body clearfix">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div id="comment-box">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="jumbotron">
|
||||
<div class="container">
|
||||
<h1 class="text-center">{{ challenge.name }}</h1>
|
||||
@@ -29,6 +50,10 @@
|
||||
<i class="btn-fa fas fa-tasks fa-2x px-2" data-toggle="tooltip" data-placement="top"
|
||||
title="Correct Submissions"></i>
|
||||
</a>
|
||||
<a class="comments-challenge">
|
||||
<i class="btn-fa fas fa-comments fa-2x px-2" data-toggle="tooltip" data-placement="top"
|
||||
title="Comments"></i>
|
||||
</a>
|
||||
<a class="delete-challenge">
|
||||
<i class="btn-fa fas fa-trash-alt fa-2x px-2" data-toggle="tooltip" data-placement="top"
|
||||
title="Delete Challenge"></i>
|
||||
@@ -41,23 +66,20 @@
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<nav class="nav nav-tabs nav-fill" id="challenge-properties" role="tablist">
|
||||
<a class="nav-item nav-link active" data-toggle="tab" href="#comments" role="tab" >Comments</a>
|
||||
<a class="nav-item nav-link active" data-toggle="tab" href="#files" role="tab">Files</a>
|
||||
<a class="nav-item nav-link" data-toggle="tab" href="#flags" role="tab">Flags</a>
|
||||
<a class="nav-item nav-link" data-toggle="tab" href="#files" role="tab">Files</a>
|
||||
<a class="nav-item nav-link" data-toggle="tab" href="#topics" role="tab">Topics</a>
|
||||
<a class="nav-item nav-link" data-toggle="tab" href="#tags" role="tab">Tags</a>
|
||||
<a class="nav-item nav-link" data-toggle="tab" href="#hints" role="tab">Hints</a>
|
||||
<a class="nav-item nav-link" data-toggle="tab" href="#requirements" role="tab">Requirements</a>
|
||||
</nav>
|
||||
|
||||
<div class="tab-content" id="nav-tabContent">
|
||||
<div class="tab-pane fade show active" id="comments" role="tabpanel">
|
||||
<div class="tab-pane fade show active" id="files" role="tabpanel">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h3 class="text-center py-3 d-block">
|
||||
Comments
|
||||
</h3>
|
||||
<div id="comment-box">
|
||||
</div>
|
||||
<h3 class="text-center py-3 d-block">Files</h3>
|
||||
{% include "admin/modals/challenges/files.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,11 +91,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="files" role="tabpanel">
|
||||
<div class="tab-pane fade" id="topics" role="tabpanel">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h3 class="text-center py-3 d-block">Files</h3>
|
||||
{% include "admin/modals/challenges/files.html" %}
|
||||
<h3 class="text-center py-3 d-block">Topics</h3>
|
||||
{% include "admin/modals/challenges/topics.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
<div id="challenge-topics">
|
||||
</div>
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Add topics and challenge_topics tables
|
||||
|
||||
Revision ID: ef87d69ec29a
|
||||
Revises: 07dfbe5e1edc
|
||||
Create Date: 2021-07-29 23:22:39.345426
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "ef87d69ec29a"
|
||||
down_revision = "07dfbe5e1edc"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"topics",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("value", sa.String(length=255), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("value"),
|
||||
)
|
||||
op.create_table(
|
||||
"challenge_topics",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("challenge_id", sa.Integer(), nullable=True),
|
||||
sa.Column("topic_id", sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["challenge_id"], ["challenges.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(["topic_id"], ["topics.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table("challenge_topics")
|
||||
op.drop_table("topics")
|
||||
# ### end Alembic commands ###
|
||||
@@ -15,6 +15,7 @@ from tests.helpers import (
|
||||
gen_solve,
|
||||
gen_tag,
|
||||
gen_team,
|
||||
gen_topic,
|
||||
gen_user,
|
||||
login_as_user,
|
||||
register_user,
|
||||
@@ -1313,6 +1314,34 @@ def test_api_challenge_get_tags_admin():
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_api_challenge_get_topics_non_admin():
|
||||
"""Can a user get /api/v1/challenges/<challenge_id>/topics if not admin"""
|
||||
app = create_ctfd()
|
||||
with app.app_context():
|
||||
gen_challenge(app.db)
|
||||
gen_topic(app.db, challenge_id=1)
|
||||
with app.test_client() as client:
|
||||
r = client.get("/api/v1/challenges/1/topics", json="")
|
||||
assert r.status_code == 403
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_api_challenge_get_topics_admin():
|
||||
"""Can a user get /api/v1/challenges/<challenge_id>/topics if not admin"""
|
||||
app = create_ctfd()
|
||||
with app.app_context():
|
||||
gen_challenge(app.db)
|
||||
gen_topic(app.db, challenge_id=1)
|
||||
with login_as_user(app, name="admin") as client:
|
||||
r = client.get("/api/v1/challenges/1/topics", json="")
|
||||
assert r.status_code == 200
|
||||
assert r.get_json() == {
|
||||
"success": True,
|
||||
"data": [{"id": 1, "challenge_id": 1, "topic_id": 1, "value": "topic"}],
|
||||
}
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_api_challenge_get_hints_non_admin():
|
||||
"""Can a user get /api/v1/challenges/<challenge_id>/hints if not admin"""
|
||||
app = create_ctfd()
|
||||
|
||||
@@ -91,7 +91,7 @@ def test_api_tag_patch_admin():
|
||||
|
||||
|
||||
def test_api_tag_delete_admin():
|
||||
"""Can a user patch /api/v1/tags/<tag_id> if admin"""
|
||||
"""Can a user delete /api/v1/tags/<tag_id> if admin"""
|
||||
app = create_ctfd()
|
||||
with app.app_context():
|
||||
gen_challenge(app.db)
|
||||
|
||||
132
tests/api/v1/test_topics.py
Normal file
132
tests/api/v1/test_topics.py
Normal file
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from tests.helpers import (
|
||||
create_ctfd,
|
||||
destroy_ctfd,
|
||||
gen_challenge,
|
||||
gen_topic,
|
||||
login_as_user,
|
||||
register_user,
|
||||
)
|
||||
|
||||
|
||||
def test_api_topics_non_admin():
|
||||
"""Can a user interact with /api/v1/topics if not admin"""
|
||||
app = create_ctfd()
|
||||
with app.app_context():
|
||||
gen_challenge(app.db)
|
||||
gen_topic(app.db, challenge_id=1)
|
||||
with app.test_client() as client:
|
||||
r = client.get("/api/v1/topics", json="")
|
||||
assert r.status_code == 403
|
||||
|
||||
"""Can a user post /api/v1/topics if not admin"""
|
||||
r = client.post("/api/v1/topics")
|
||||
assert r.status_code == 403
|
||||
|
||||
"""Can a user delete /api/v1/topics if not admin"""
|
||||
r = client.delete("/api/v1/topics")
|
||||
assert r.status_code == 403
|
||||
|
||||
"""Can a user get /api/v1/topics/<topic_id> if not admin"""
|
||||
r = client.get("/api/v1/topics/1", json="")
|
||||
assert r.status_code == 403
|
||||
|
||||
"""Can a user delete /api/v1/topics/<topic_id> if not admin"""
|
||||
r = client.delete("/api/v1/topics/1", json="")
|
||||
assert r.status_code == 403
|
||||
|
||||
register_user(app)
|
||||
with login_as_user(app) as client:
|
||||
r = client.get("/api/v1/topics", json="")
|
||||
assert r.status_code == 403
|
||||
|
||||
"""Can a user post /api/v1/topics if not admin"""
|
||||
r = client.post("/api/v1/topics")
|
||||
assert r.status_code == 403
|
||||
|
||||
"""Can a user delete /api/v1/topics if not admin"""
|
||||
r = client.delete("/api/v1/topics")
|
||||
assert r.status_code == 403
|
||||
|
||||
"""Can a user get /api/v1/topics/<topic_id> if not admin"""
|
||||
r = client.get("/api/v1/topics/1", json="")
|
||||
assert r.status_code == 403
|
||||
|
||||
"""Can a user delete /api/v1/topics/<topic_id> if not admin"""
|
||||
r = client.delete("/api/v1/topics/1", json="")
|
||||
assert r.status_code == 403
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_api_topics_get_admin():
|
||||
"""Can a user get /api/v1/topics if admin"""
|
||||
app = create_ctfd()
|
||||
with app.app_context():
|
||||
gen_challenge(app.db)
|
||||
gen_topic(app.db, challenge_id=1)
|
||||
gen_topic(app.db, challenge_id=1, value="topic2")
|
||||
with login_as_user(app, name="admin") as client:
|
||||
r = client.get("/api/v1/topics")
|
||||
assert r.status_code == 200
|
||||
assert r.get_json() == {
|
||||
"success": True,
|
||||
"data": [{"id": 1, "value": "topic"}, {"id": 2, "value": "topic2"}],
|
||||
}
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_api_topics_post_admin():
|
||||
"""Can a user post /api/v1/topics if admin"""
|
||||
app = create_ctfd()
|
||||
with app.app_context():
|
||||
gen_challenge(app.db)
|
||||
with login_as_user(app, name="admin") as client:
|
||||
r = client.post(
|
||||
"/api/v1/topics",
|
||||
json={"value": "topic", "type": "challenge", "challenge_id": 1},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
print(r.get_json())
|
||||
assert r.get_json() == {
|
||||
"success": True,
|
||||
"data": {
|
||||
"challenge_id": 1,
|
||||
"challenge": 1,
|
||||
"topic": 1,
|
||||
"id": 1,
|
||||
"topic_id": 1,
|
||||
},
|
||||
}
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_api_topics_delete_admin():
|
||||
"""Can a user delete /api/v1/topics/<topic_id> if admin"""
|
||||
app = create_ctfd()
|
||||
with app.app_context():
|
||||
gen_challenge(app.db)
|
||||
gen_topic(app.db, challenge_id=1)
|
||||
with login_as_user(app, "admin") as client:
|
||||
r = client.delete("/api/v1/topics/1", json="")
|
||||
assert r.status_code == 200
|
||||
resp = r.get_json()
|
||||
assert resp.get("data") is None
|
||||
assert resp.get("success") is True
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_api_topics_delete_target_admin():
|
||||
"""Can a user delete /api/v1/topics if admin"""
|
||||
app = create_ctfd()
|
||||
with app.app_context():
|
||||
gen_challenge(app.db)
|
||||
gen_topic(app.db, challenge_id=1)
|
||||
with login_as_user(app, "admin") as client:
|
||||
r = client.delete("/api/v1/topics?type=challenge&target_id=1", json="")
|
||||
assert r.status_code == 200
|
||||
resp = r.get_json()
|
||||
assert resp.get("data") is None
|
||||
assert resp.get("success") is True
|
||||
destroy_ctfd(app)
|
||||
@@ -22,6 +22,7 @@ from CTFd.models import (
|
||||
ChallengeComments,
|
||||
ChallengeFiles,
|
||||
Challenges,
|
||||
ChallengeTopics,
|
||||
Comments,
|
||||
Fails,
|
||||
Fields,
|
||||
@@ -37,6 +38,7 @@ from CTFd.models import (
|
||||
TeamComments,
|
||||
Teams,
|
||||
Tokens,
|
||||
Topics,
|
||||
Tracking,
|
||||
Unlocks,
|
||||
UserComments,
|
||||
@@ -353,6 +355,17 @@ def gen_tag(db, challenge_id, value="tag_tag", **kwargs):
|
||||
return tag
|
||||
|
||||
|
||||
def gen_topic(db, challenge_id, value="topic", **kwargs):
|
||||
topic = Topics(value=value, **kwargs)
|
||||
db.session.add(topic)
|
||||
db.session.commit()
|
||||
|
||||
challenge_topic = ChallengeTopics(challenge_id=challenge_id, topic_id=topic.id)
|
||||
db.session.add(challenge_topic)
|
||||
db.session.commit()
|
||||
return challenge_topic
|
||||
|
||||
|
||||
def gen_file(db, location, challenge_id=None, page_id=None):
|
||||
if challenge_id:
|
||||
f = ChallengeFiles(challenge_id=challenge_id, location=location)
|
||||
|
||||
Reference in New Issue
Block a user