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:
Kevin Chung
2021-07-30 00:03:16 -04:00
committed by GitHub
parent 22a0c0b007
commit 27d862ab29
18 changed files with 788 additions and 26 deletions

View File

@@ -21,6 +21,7 @@ from CTFd.api.v1.submissions import submissions_namespace
from CTFd.api.v1.tags import tags_namespace from CTFd.api.v1.tags import tags_namespace
from CTFd.api.v1.teams import teams_namespace from CTFd.api.v1.teams import teams_namespace
from CTFd.api.v1.tokens import tokens_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.unlocks import unlocks_namespace
from CTFd.api.v1.users import users_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(challenges_namespace, "/challenges")
CTFd_API_v1.add_namespace(tags_namespace, "/tags") 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(awards_namespace, "/awards")
CTFd_API_v1.add_namespace(hints_namespace, "/hints") CTFd_API_v1.add_namespace(hints_namespace, "/hints")
CTFd_API_v1.add_namespace(flags_namespace, "/flags") CTFd_API_v1.add_namespace(flags_namespace, "/flags")

View File

@@ -12,17 +12,9 @@ from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessRespon
from CTFd.cache import clear_standings from CTFd.cache import clear_standings
from CTFd.constants import RawEnum from CTFd.constants import RawEnum
from CTFd.models import ChallengeFiles as ChallengeFilesModel from CTFd.models import ChallengeFiles as ChallengeFilesModel
from CTFd.models import ( from CTFd.models import Challenges
Challenges, from CTFd.models import ChallengeTopics as ChallengeTopicsModel
Fails, from CTFd.models import Fails, Flags, Hints, HintUnlocks, Solves, Submissions, Tags, db
Flags,
Hints,
HintUnlocks,
Solves,
Submissions,
Tags,
db,
)
from CTFd.plugins.challenges import CHALLENGE_CLASSES, get_chal_class from CTFd.plugins.challenges import CHALLENGE_CLASSES, get_chal_class
from CTFd.schemas.challenges import ChallengeSchema from CTFd.schemas.challenges import ChallengeSchema
from CTFd.schemas.flags import FlagSchema from CTFd.schemas.flags import FlagSchema
@@ -831,6 +823,26 @@ class ChallengeTags(Resource):
return {"success": True, "data": response} 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") @challenges_namespace.route("/<challenge_id>/hints")
class ChallengeHints(Resource): class ChallengeHints(Resource):
@admins_only @admins_only

177
CTFd/api/v1/topics.py Normal file
View 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}

View File

@@ -97,6 +97,7 @@ class Challenges(db.Model):
hints = db.relationship("Hints", backref="challenge") hints = db.relationship("Hints", backref="challenge")
flags = db.relationship("Flags", backref="challenge") flags = db.relationship("Flags", backref="challenge")
comments = db.relationship("ChallengeComments", backref="challenge") comments = db.relationship("ChallengeComments", backref="challenge")
topics = db.relationship("ChallengeTopics", backref="challenge")
class alt_defaultdict(defaultdict): class alt_defaultdict(defaultdict):
""" """
@@ -222,6 +223,31 @@ class Tags(db.Model):
super(Tags, self).__init__(**kwargs) 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): class Files(db.Model):
__tablename__ = "files" __tablename__ = "files"
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)

38
CTFd/schemas/topics.py Normal file
View 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)

View 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)"> &#215;</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>

View File

@@ -11,6 +11,7 @@ import Vue from "vue/dist/vue.esm.browser";
import CommentBox from "../components/comments/CommentBox.vue"; import CommentBox from "../components/comments/CommentBox.vue";
import FlagList from "../components/flags/FlagList.vue"; import FlagList from "../components/flags/FlagList.vue";
import Requirements from "../components/requirements/Requirements.vue"; import Requirements from "../components/requirements/Requirements.vue";
import TopicsList from "../components/topics/TopicsList.vue";
import TagsList from "../components/tags/TagsList.vue"; import TagsList from "../components/tags/TagsList.vue";
import ChallengeFilesList from "../components/files/ChallengeFilesList.vue"; import ChallengeFilesList from "../components/files/ChallengeFilesList.vue";
import HintsList from "../components/hints/HintsList.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) { $(".delete-challenge").click(function(_e) {
ezQuery({ ezQuery({
title: "Delete Challenge", title: "Delete Challenge",
@@ -438,6 +443,16 @@ $(() => {
}).$mount(vueContainer); }).$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 // Load TagsList component
if (document.querySelector("#challenge-tags")) { if (document.querySelector("#challenge-tags")) {
const tagList = Vue.extend(TagsList); const tagList = Vue.extend(TagsList);

View File

@@ -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/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& ***! !*** ./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/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& ***! !*** ./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/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& ***! !*** ./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

View File

@@ -8,6 +8,27 @@
<div class="modal fade" id="challenge-window" role="dialog"> <div class="modal fade" id="challenge-window" role="dialog">
</div> </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">&times;</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="jumbotron">
<div class="container"> <div class="container">
<h1 class="text-center">{{ challenge.name }}</h1> <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" <i class="btn-fa fas fa-tasks fa-2x px-2" data-toggle="tooltip" data-placement="top"
title="Correct Submissions"></i> title="Correct Submissions"></i>
</a> </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"> <a class="delete-challenge">
<i class="btn-fa fas fa-trash-alt fa-2x px-2" data-toggle="tooltip" data-placement="top" <i class="btn-fa fas fa-trash-alt fa-2x px-2" data-toggle="tooltip" data-placement="top"
title="Delete Challenge"></i> title="Delete Challenge"></i>
@@ -41,23 +66,20 @@
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<nav class="nav nav-tabs nav-fill" id="challenge-properties" role="tablist"> <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="#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="#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="#hints" role="tab">Hints</a>
<a class="nav-item nav-link" data-toggle="tab" href="#requirements" role="tab">Requirements</a> <a class="nav-item nav-link" data-toggle="tab" href="#requirements" role="tab">Requirements</a>
</nav> </nav>
<div class="tab-content" id="nav-tabContent"> <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="row">
<div class="col-md-12"> <div class="col-md-12">
<h3 class="text-center py-3 d-block"> <h3 class="text-center py-3 d-block">Files</h3>
Comments {% include "admin/modals/challenges/files.html" %}
</h3>
<div id="comment-box">
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -69,11 +91,11 @@
</div> </div>
</div> </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="row">
<div class="col-md-12"> <div class="col-md-12">
<h3 class="text-center py-3 d-block">Files</h3> <h3 class="text-center py-3 d-block">Topics</h3>
{% include "admin/modals/challenges/files.html" %} {% include "admin/modals/challenges/topics.html" %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,2 @@
<div id="challenge-topics">
</div>

View File

@@ -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 ###

View File

@@ -15,6 +15,7 @@ from tests.helpers import (
gen_solve, gen_solve,
gen_tag, gen_tag,
gen_team, gen_team,
gen_topic,
gen_user, gen_user,
login_as_user, login_as_user,
register_user, register_user,
@@ -1313,6 +1314,34 @@ def test_api_challenge_get_tags_admin():
destroy_ctfd(app) 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(): def test_api_challenge_get_hints_non_admin():
"""Can a user get /api/v1/challenges/<challenge_id>/hints if not admin""" """Can a user get /api/v1/challenges/<challenge_id>/hints if not admin"""
app = create_ctfd() app = create_ctfd()

View File

@@ -91,7 +91,7 @@ def test_api_tag_patch_admin():
def test_api_tag_delete_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() app = create_ctfd()
with app.app_context(): with app.app_context():
gen_challenge(app.db) gen_challenge(app.db)

132
tests/api/v1/test_topics.py Normal file
View 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)

View File

@@ -22,6 +22,7 @@ from CTFd.models import (
ChallengeComments, ChallengeComments,
ChallengeFiles, ChallengeFiles,
Challenges, Challenges,
ChallengeTopics,
Comments, Comments,
Fails, Fails,
Fields, Fields,
@@ -37,6 +38,7 @@ from CTFd.models import (
TeamComments, TeamComments,
Teams, Teams,
Tokens, Tokens,
Topics,
Tracking, Tracking,
Unlocks, Unlocks,
UserComments, UserComments,
@@ -353,6 +355,17 @@ def gen_tag(db, challenge_id, value="tag_tag", **kwargs):
return tag 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): def gen_file(db, location, challenge_id=None, page_id=None):
if challenge_id: if challenge_id:
f = ChallengeFiles(challenge_id=challenge_id, location=location) f = ChallengeFiles(challenge_id=challenge_id, location=location)