321 comments functionality (#1596)

* Add a comments functionality for admins to discuss challenges, users, teams, pages
* Adds `/api/v1/comments`
* Adds a `CommentBox.vue` component for the Admin Panel
* Closes #321
This commit is contained in:
Kevin Chung
2020-08-12 19:55:47 -04:00
committed by GitHub
parent 6559846452
commit b73433e1c9
23 changed files with 1095 additions and 30 deletions

View File

@@ -3,6 +3,7 @@ from flask_restx import Api
from CTFd.api.v1.awards import awards_namespace
from CTFd.api.v1.challenges import challenges_namespace
from CTFd.api.v1.comments import comments_namespace
from CTFd.api.v1.config import configs_namespace
from CTFd.api.v1.files import files_namespace
from CTFd.api.v1.flags import flags_namespace
@@ -48,3 +49,4 @@ CTFd_API_v1.add_namespace(configs_namespace, "/configs")
CTFd_API_v1.add_namespace(pages_namespace, "/pages")
CTFd_API_v1.add_namespace(unlocks_namespace, "/unlocks")
CTFd_API_v1.add_namespace(tokens_namespace, "/tokens")
CTFd_API_v1.add_namespace(comments_namespace, "/comments")

141
CTFd/api/v1/comments.py Normal file
View File

@@ -0,0 +1,141 @@
from typing import List
from flask import request, session
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.models import build_model_filters
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 (
ChallengeComments,
Comments,
PageComments,
TeamComments,
UserComments,
db,
)
from CTFd.schemas.comments import CommentSchema
from CTFd.utils.decorators import admins_only
comments_namespace = Namespace("comments", description="Endpoint to retrieve Comments")
CommentModel = sqlalchemy_to_pydantic(Comments)
class CommentDetailedSuccessResponse(APIDetailedSuccessResponse):
data: CommentModel
class CommentListSuccessResponse(APIListSuccessResponse):
data: List[CommentModel]
comments_namespace.schema_model(
"CommentDetailedSuccessResponse", CommentDetailedSuccessResponse.apidoc()
)
comments_namespace.schema_model(
"CommentListSuccessResponse", CommentListSuccessResponse.apidoc()
)
def get_comment_model(data):
model = Comments
if "challenge_id" in data:
model = ChallengeComments
elif "user_id" in data:
model = UserComments
elif "team_id" in data:
model = TeamComments
elif "page_id" in data:
model = PageComments
else:
model = Comments
return model
@comments_namespace.route("")
class CommentList(Resource):
@admins_only
@comments_namespace.doc(
description="Endpoint to list Comment objects in bulk",
responses={
200: ("Success", "CommentListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
@validate_args(
{
"challenge_id": (int, None),
"user_id": (int, None),
"team_id": (int, None),
"page_id": (int, None),
"q": (str, None),
"field": (RawEnum("CommentFields", {"content": "content"}), None,),
},
location="query",
)
def get(self, query_args):
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
CommentModel = get_comment_model(data=query_args)
filters = build_model_filters(model=CommentModel, query=q, field=field)
comments = CommentModel.query.filter_by(**query_args).filter(*filters).all()
schema = CommentSchema(many=True)
response = schema.dump(comments)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
@admins_only
@comments_namespace.doc(
description="Endpoint to create a Comment object",
responses={
200: ("Success", "CommentDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self):
req = request.get_json()
# Always force author IDs to be the actual user
req["author_id"] = session["id"]
CommentModel = get_comment_model(data=req)
m = CommentModel(**req)
db.session.add(m)
db.session.commit()
schema = CommentSchema()
response = schema.dump(m)
db.session.close()
return {"success": True, "data": response.data}
@comments_namespace.route("/<comment_id>")
class Comment(Resource):
@admins_only
@comments_namespace.doc(
description="Endpoint to delete a specific Comment object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, comment_id):
comment = Comments.query.filter_by(id=comment_id).first_or_404()
db.session.delete(comment)
db.session.commit()
db.session.close()
return {"success": True}

View File

@@ -77,6 +77,7 @@ class Challenges(db.Model):
tags = db.relationship("Tags", backref="challenge")
hints = db.relationship("Hints", backref="challenge")
flags = db.relationship("Flags", backref="challenge")
comments = db.relationship("ChallengeComments", backref="challenge")
class alt_defaultdict(defaultdict):
"""
@@ -739,3 +740,44 @@ class Tokens(db.Model):
class UserTokens(Tokens):
__mapper_args__ = {"polymorphic_identity": "user"}
class Comments(db.Model):
__tablename__ = "comments"
id = db.Column(db.Integer, primary_key=True)
type = db.Column(db.String(80), default="standard")
content = db.Column(db.Text)
date = db.Column(db.DateTime, default=datetime.datetime.utcnow)
author_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"))
author = db.relationship("Users", foreign_keys="Comments.author_id", lazy="select")
@property
def html(self):
from CTFd.utils.config.pages import build_html
from CTFd.utils.helpers import markup
return markup(build_html(self.content, sanitize=True))
__mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type}
class ChallengeComments(Comments):
__mapper_args__ = {"polymorphic_identity": "challenge"}
challenge_id = db.Column(
db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE")
)
class UserComments(Comments):
__mapper_args__ = {"polymorphic_identity": "user"}
user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"))
class TeamComments(Comments):
__mapper_args__ = {"polymorphic_identity": "team"}
team_id = db.Column(db.Integer, db.ForeignKey("teams.id", ondelete="CASCADE"))
class PageComments(Comments):
__mapper_args__ = {"polymorphic_identity": "page"}
page_id = db.Column(db.Integer, db.ForeignKey("pages.id", ondelete="CASCADE"))

14
CTFd/schemas/comments.py Normal file
View File

@@ -0,0 +1,14 @@
from marshmallow import fields
from CTFd.models import Comments, ma
from CTFd.schemas.users import UserSchema
class CommentSchema(ma.ModelSchema):
class Meta:
model = Comments
include_fk = True
dump_only = ("id", "date", "html", "author", "author_id", "type")
author = fields.Nested(UserSchema(only=("name",)))
html = fields.String()

View File

@@ -0,0 +1,155 @@
<template>
<div>
<div class="row mb-3">
<div class="col-md-12">
<div class="comment">
<textarea
class="form-control mb-2"
rows="2"
id="comment-input"
placeholder="Add comment"
v-model.lazy="comment"
></textarea>
<button
class="btn btn-sm btn-success btn-outlined float-right"
type="submit"
@click="submitComment()"
>
Comment
</button>
</div>
</div>
</div>
<div class="comments">
<transition-group name="comment-card">
<div
class="comment-card card mb-2"
v-for="comment in comments.slice().reverse()"
:key="comment.id"
>
<div class="card-body pl-0 pb-0 pt-2 pr-2">
<button
type="button"
class="close float-right"
aria-label="Close"
@click="deleteComment(comment.id)"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="card-body">
<div class="card-text" v-html="comment.html"></div>
<small class="text-muted float-left">
<span>
<a :href="`${urlRoot}/admin/users/${comment.author_id}`">{{
comment.author.name
}}</a>
</span>
</small>
<small class="text-muted float-right">
<span class="float-right">{{ toLocalTime(comment.date) }}</span>
</small>
</div>
</div>
</transition-group>
</div>
</div>
</template>
<script>
import CTFd from "core/CTFd";
import { default as helpers } from "core/helpers";
import Moment from "moment";
export default {
props: {
// These props are passed to the api via query string.
// See this.getArgs()
type: String,
id: Number
},
data: function() {
return {
comment: "",
comments: [],
urlRoot: CTFd.config.urlRoot
};
},
methods: {
toLocalTime(date) {
return Moment(date)
.local()
.format("MMMM Do, h:mm:ss A");
},
getArgs: function() {
let args = {};
args[`${this.$props.type}_id`] = this.$props.id;
return args;
},
loadComments: function() {
let apiArgs = this.getArgs();
helpers.comments.get_comments(apiArgs).then(response => {
this.comments = response.data;
return this.comments;
});
},
submitComment: function() {
let comment = this.comment.trim();
if (comment.length > 0) {
helpers.comments.add_comment(
comment,
"challenge",
this.getArgs(),
() => {
this.loadComments();
}
);
}
this.comment = "";
},
deleteComment: function(commentId) {
if (confirm("Are you sure you'd like to delete this comment?")) {
helpers.comments.delete_comment(commentId).then(response => {
if (response.success === true) {
for (let i = this.comments.length - 1; i >= 0; --i) {
if (this.comments[i].id == commentId) {
this.comments.splice(i, 1);
}
}
}
});
}
}
},
created() {
this.loadComments();
}
};
</script>
<style scoped>
.card .close {
opacity: 0;
transition: 0.2s;
}
.card:hover .close {
opacity: 0.5;
}
.close:hover {
opacity: 0.75 !important;
}
.comment-card-leave {
max-height: 200px;
}
.comment-card-leave-to {
max-height: 0;
}
.comment-card-active {
position: absolute;
}
.comment-card-enter-active,
.comment-card-move,
.comment-card-leave-active {
transition: all 0.3s;
}
</style>

View File

@@ -10,6 +10,8 @@ import { addFile, deleteFile } from "../challenges/files";
import { addTag, deleteTag } from "../challenges/tags";
import { addRequirement, deleteRequirement } from "../challenges/requirements";
import { bindMarkdownEditors } from "../styles";
import Vue from "vue/dist/vue.esm.browser";
import CommentBox from "../components/comments/CommentBox.vue";
import {
showHintModal,
editHint,
@@ -423,6 +425,14 @@ $(() => {
$("#flags-create-select").change(flagTypeSelect);
$(".edit-flag").click(editFlagModal);
// Insert CommentBox element
const commentBox = Vue.extend(CommentBox);
let vueContainer = document.createElement("div");
document.querySelector("#comment-box").appendChild(vueContainer);
new commentBox({
propsData: { type: "challenge", id: window.CHALLENGE_ID }
}).$mount(vueContainer);
$.get(CTFd.config.urlRoot + "/api/v1/challenges/types", function(response) {
const data = response.data;
loadChalTemplate(data["standard"]);

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

@@ -1 +1 @@
(window.webpackJsonp=window.webpackJsonp||[]).push([[0],{"./CTFd/themes/core/assets/js/helpers.js":function(e,r,o){Object.defineProperty(r,"__esModule",{value:!0}),r.default=void 0;var p=n(o("./node_modules/jquery/dist/jquery.js")),c=n(o("./CTFd/themes/core/assets/js/ezq.js")),t=o("./CTFd/themes/core/assets/js/utils.js");function n(e){return e&&e.__esModule?e:{default:e}}function f(e,r){return function(e){if(Array.isArray(e))return e}(e)||function(e,r){var o=[],t=!0,n=!1,s=void 0;try{for(var i,a=e[Symbol.iterator]();!(t=(i=a.next()).done)&&(o.push(i.value),!r||o.length!==r);t=!0);}catch(e){n=!0,s=e}finally{try{t||null==a.return||a.return()}finally{if(n)throw s}}return o}(e,r)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance")}()}var s={files:{upload:function(r,e,o){var t=window.CTFd;r instanceof p.default&&(r=r[0]);var n=new FormData(r);n.append("nonce",t.config.csrfNonce);for(var s=0,i=Object.entries(e);s<i.length;s++){var a=f(i[s],2),l=a[0],d=a[1];n.append(l,d)}var u=c.default.ezProgressBar({width:0,title:"Upload Progress"});p.default.ajax({url:t.config.urlRoot+"/api/v1/files",data:n,type:"POST",cache:!1,contentType:!1,processData:!1,xhr:function(){var e=p.default.ajaxSettings.xhr();return e.upload.onprogress=function(e){if(e.lengthComputable){var r=e.loaded/e.total*100;u=c.default.ezProgressBar({target:u,width:r})}},e},success:function(e){r.reset(),u=c.default.ezProgressBar({target:u,width:100}),setTimeout(function(){u.modal("hide")},500),o&&o(e)}})}},utils:{htmlEntities:t.htmlEntities,colorHash:t.colorHash,copyToClipboard:t.copyToClipboard},ezq:c.default};r.default=s},"./node_modules/markdown-it/lib/helpers/index.js":function(e,r,o){r.parseLinkLabel=o("./node_modules/markdown-it/lib/helpers/parse_link_label.js"),r.parseLinkDestination=o("./node_modules/markdown-it/lib/helpers/parse_link_destination.js"),r.parseLinkTitle=o("./node_modules/markdown-it/lib/helpers/parse_link_title.js")},"./node_modules/markdown-it/lib/helpers/parse_link_destination.js":function(e,r,o){var a=o("./node_modules/markdown-it/lib/common/utils.js").unescapeAll;e.exports=function(e,r,o){var t,n,s=r,i={ok:!1,pos:0,lines:0,str:""};if(60===e.charCodeAt(r)){for(r++;r<o;){if(10===(t=e.charCodeAt(r)))return i;if(62===t)return i.pos=r+1,i.str=a(e.slice(s+1,r)),i.ok=!0,i;92===t&&r+1<o?r+=2:r++}return i}for(n=0;r<o&&32!==(t=e.charCodeAt(r))&&!(t<32||127===t);)if(92===t&&r+1<o)r+=2;else{if(40===t&&n++,41===t){if(0===n)break;n--}r++}return s===r||0!==n||(i.str=a(e.slice(s,r)),i.lines=0,i.pos=r,i.ok=!0),i}},"./node_modules/markdown-it/lib/helpers/parse_link_label.js":function(e,r,o){e.exports=function(e,r,o){var t,n,s,i,a=-1,l=e.posMax,d=e.pos;for(e.pos=r+1,t=1;e.pos<l;){if(93===(s=e.src.charCodeAt(e.pos))&&0===--t){n=!0;break}if(i=e.pos,e.md.inline.skipToken(e),91===s)if(i===e.pos-1)t++;else if(o)return e.pos=d,-1}return n&&(a=e.pos),e.pos=d,a}},"./node_modules/markdown-it/lib/helpers/parse_link_title.js":function(e,r,o){var l=o("./node_modules/markdown-it/lib/common/utils.js").unescapeAll;e.exports=function(e,r,o){var t,n,s=0,i=r,a={ok:!1,pos:0,lines:0,str:""};if(o<=r)return a;if(34!==(n=e.charCodeAt(r))&&39!==n&&40!==n)return a;for(r++,40===n&&(n=41);r<o;){if((t=e.charCodeAt(r))===n)return a.pos=r+1,a.lines=s,a.str=l(e.slice(i+1,r)),a.ok=!0,a;10===t?s++:92===t&&r+1<o&&(r++,10===e.charCodeAt(r)&&s++),r++}return a}}}]);
(window.webpackJsonp=window.webpackJsonp||[]).push([[0],{"./CTFd/themes/core/assets/js/helpers.js":function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var d=r(n("./node_modules/jquery/dist/jquery.js")),p=r(n("./CTFd/themes/core/assets/js/ezq.js")),o=n("./CTFd/themes/core/assets/js/utils.js");function r(e){return e&&e.__esModule?e:{default:e}}function s(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function f(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=[],o=!0,r=!1,i=void 0;try{for(var s,a=e[Symbol.iterator]();!(o=(s=a.next()).done)&&(n.push(s.value),!t||n.length!==t);o=!0);}catch(e){r=!0,i=e}finally{try{o||null==a.return||a.return()}finally{if(r)throw i}}return n}(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance")}()}var i={files:{upload:function(t,e,n){var o=window.CTFd;t instanceof d.default&&(t=t[0]);var r=new FormData(t);r.append("nonce",o.config.csrfNonce);for(var i=0,s=Object.entries(e);i<s.length;i++){var a=f(s[i],2),l=a[0],c=a[1];r.append(l,c)}var u=p.default.ezProgressBar({width:0,title:"Upload Progress"});d.default.ajax({url:o.config.urlRoot+"/api/v1/files",data:r,type:"POST",cache:!1,contentType:!1,processData:!1,xhr:function(){var e=d.default.ajaxSettings.xhr();return e.upload.onprogress=function(e){if(e.lengthComputable){var t=e.loaded/e.total*100;u=p.default.ezProgressBar({target:u,width:t})}},e},success:function(e){t.reset(),u=p.default.ezProgressBar({target:u,width:100}),setTimeout(function(){u.modal("hide")},500),n&&n(e)}})}},comments:{get_comments:function(e){return window.CTFd.fetch("/api/v1/comments?"+d.default.param(e),{method:"GET",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()})},add_comment:function(e,t,n,o){var r=window.CTFd,i=function(t){for(var e=1;e<arguments.length;e++)if(e%2){var n=null!=arguments[e]?arguments[e]:{},o=Object.keys(n);"function"==typeof Object.getOwnPropertySymbols&&(o=o.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),o.forEach(function(e){s(t,e,n[e])})}else Object.defineProperties(t,Object.getOwnPropertyDescriptors(arguments[e]));return t}({content:e,type:t},n);r.fetch("/api/v1/comments",{method:"POST",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"},body:JSON.stringify(i)}).then(function(e){return e.json()}).then(function(e){o&&o(e)})},delete_comment:function(e){return window.CTFd.fetch("/api/v1/comments/".concat(e),{method:"DELETE",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()})}},utils:{htmlEntities:o.htmlEntities,colorHash:o.colorHash,copyToClipboard:o.copyToClipboard},ezq:p.default};t.default=i},"./node_modules/markdown-it/lib/helpers/index.js":function(e,t,n){t.parseLinkLabel=n("./node_modules/markdown-it/lib/helpers/parse_link_label.js"),t.parseLinkDestination=n("./node_modules/markdown-it/lib/helpers/parse_link_destination.js"),t.parseLinkTitle=n("./node_modules/markdown-it/lib/helpers/parse_link_title.js")},"./node_modules/markdown-it/lib/helpers/parse_link_destination.js":function(e,t,n){var a=n("./node_modules/markdown-it/lib/common/utils.js").unescapeAll;e.exports=function(e,t,n){var o,r,i=t,s={ok:!1,pos:0,lines:0,str:""};if(60===e.charCodeAt(t)){for(t++;t<n;){if(10===(o=e.charCodeAt(t)))return s;if(62===o)return s.pos=t+1,s.str=a(e.slice(i+1,t)),s.ok=!0,s;92===o&&t+1<n?t+=2:t++}return s}for(r=0;t<n&&32!==(o=e.charCodeAt(t))&&!(o<32||127===o);)if(92===o&&t+1<n)t+=2;else{if(40===o&&r++,41===o){if(0===r)break;r--}t++}return i===t||0!==r||(s.str=a(e.slice(i,t)),s.lines=0,s.pos=t,s.ok=!0),s}},"./node_modules/markdown-it/lib/helpers/parse_link_label.js":function(e,t,n){e.exports=function(e,t,n){var o,r,i,s,a=-1,l=e.posMax,c=e.pos;for(e.pos=t+1,o=1;e.pos<l;){if(93===(i=e.src.charCodeAt(e.pos))&&0===--o){r=!0;break}if(s=e.pos,e.md.inline.skipToken(e),91===i)if(s===e.pos-1)o++;else if(n)return e.pos=c,-1}return r&&(a=e.pos),e.pos=c,a}},"./node_modules/markdown-it/lib/helpers/parse_link_title.js":function(e,t,n){var l=n("./node_modules/markdown-it/lib/common/utils.js").unescapeAll;e.exports=function(e,t,n){var o,r,i=0,s=t,a={ok:!1,pos:0,lines:0,str:""};if(n<=t)return a;if(34!==(r=e.charCodeAt(t))&&39!==r&&40!==r)return a;for(t++,40===r&&(r=41);t<n;){if((o=e.charCodeAt(t))===r)return a.pos=t+1,a.lines=i,a.str=l(e.slice(s+1,t)),a.ok=!0,a;10===o?i++:92===o&&t+1<n&&(t++,10===e.charCodeAt(t)&&i++),t++}return a}}}]);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -45,7 +45,7 @@
<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="#solves" role="tab" >Solves</a>
<a class="nav-item nav-link active" data-toggle="tab" href="#comments" role="tab" >Comments</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="#tags" role="tab">Tags</a>
@@ -54,11 +54,14 @@
</nav>
<div class="tab-content" id="nav-tabContent">
<div class="tab-pane fade show active" id="solves" role="tabpanel">
<div class="tab-pane fade show active" id="comments" role="tabpanel">
<div class="row">
<div class="col-md-12">
<h3 class="text-center py-3 d-block">Solves</h3>
{% include "admin/modals/challenges/solves.html" %}
<h3 class="text-center py-3 d-block">
Comments
</h3>
<div id="comment-box">
</div>
</div>
</div>
</div>

View File

@@ -63,8 +63,63 @@ const files = {
}
};
const comments = {
get_comments: extra_args => {
const CTFd = window.CTFd;
return CTFd.fetch("/api/v1/comments?" + $.param(extra_args), {
method: "GET",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
}
}).then(function(response) {
return response.json();
});
},
add_comment: (comment, type, extra_args, cb) => {
const CTFd = window.CTFd;
let body = {
content: comment,
type: type,
...extra_args
};
CTFd.fetch("/api/v1/comments", {
method: "POST",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify(body)
})
.then(function(response) {
return response.json();
})
.then(function(response) {
if (cb) {
cb(response);
}
});
},
delete_comment: comment_id => {
const CTFd = window.CTFd;
return CTFd.fetch(`/api/v1/comments/${comment_id}`, {
method: "DELETE",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
}
}).then(function(response) {
return response.json();
});
}
};
const helpers = {
files,
comments,
utils,
ezq
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
(window.webpackJsonp=window.webpackJsonp||[]).push([[0],{"./CTFd/themes/core/assets/js/helpers.js":function(e,r,o){Object.defineProperty(r,"__esModule",{value:!0}),r.default=void 0;var p=n(o("./node_modules/jquery/dist/jquery.js")),c=n(o("./CTFd/themes/core/assets/js/ezq.js")),t=o("./CTFd/themes/core/assets/js/utils.js");function n(e){return e&&e.__esModule?e:{default:e}}function f(e,r){return function(e){if(Array.isArray(e))return e}(e)||function(e,r){var o=[],t=!0,n=!1,s=void 0;try{for(var i,a=e[Symbol.iterator]();!(t=(i=a.next()).done)&&(o.push(i.value),!r||o.length!==r);t=!0);}catch(e){n=!0,s=e}finally{try{t||null==a.return||a.return()}finally{if(n)throw s}}return o}(e,r)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance")}()}var s={files:{upload:function(r,e,o){var t=window.CTFd;r instanceof p.default&&(r=r[0]);var n=new FormData(r);n.append("nonce",t.config.csrfNonce);for(var s=0,i=Object.entries(e);s<i.length;s++){var a=f(i[s],2),l=a[0],d=a[1];n.append(l,d)}var u=c.default.ezProgressBar({width:0,title:"Upload Progress"});p.default.ajax({url:t.config.urlRoot+"/api/v1/files",data:n,type:"POST",cache:!1,contentType:!1,processData:!1,xhr:function(){var e=p.default.ajaxSettings.xhr();return e.upload.onprogress=function(e){if(e.lengthComputable){var r=e.loaded/e.total*100;u=c.default.ezProgressBar({target:u,width:r})}},e},success:function(e){r.reset(),u=c.default.ezProgressBar({target:u,width:100}),setTimeout(function(){u.modal("hide")},500),o&&o(e)}})}},utils:{htmlEntities:t.htmlEntities,colorHash:t.colorHash,copyToClipboard:t.copyToClipboard},ezq:c.default};r.default=s},"./node_modules/markdown-it/lib/helpers/index.js":function(e,r,o){r.parseLinkLabel=o("./node_modules/markdown-it/lib/helpers/parse_link_label.js"),r.parseLinkDestination=o("./node_modules/markdown-it/lib/helpers/parse_link_destination.js"),r.parseLinkTitle=o("./node_modules/markdown-it/lib/helpers/parse_link_title.js")},"./node_modules/markdown-it/lib/helpers/parse_link_destination.js":function(e,r,o){var a=o("./node_modules/markdown-it/lib/common/utils.js").unescapeAll;e.exports=function(e,r,o){var t,n,s=r,i={ok:!1,pos:0,lines:0,str:""};if(60===e.charCodeAt(r)){for(r++;r<o;){if(10===(t=e.charCodeAt(r)))return i;if(62===t)return i.pos=r+1,i.str=a(e.slice(s+1,r)),i.ok=!0,i;92===t&&r+1<o?r+=2:r++}return i}for(n=0;r<o&&32!==(t=e.charCodeAt(r))&&!(t<32||127===t);)if(92===t&&r+1<o)r+=2;else{if(40===t&&n++,41===t){if(0===n)break;n--}r++}return s===r||0!==n||(i.str=a(e.slice(s,r)),i.lines=0,i.pos=r,i.ok=!0),i}},"./node_modules/markdown-it/lib/helpers/parse_link_label.js":function(e,r,o){e.exports=function(e,r,o){var t,n,s,i,a=-1,l=e.posMax,d=e.pos;for(e.pos=r+1,t=1;e.pos<l;){if(93===(s=e.src.charCodeAt(e.pos))&&0===--t){n=!0;break}if(i=e.pos,e.md.inline.skipToken(e),91===s)if(i===e.pos-1)t++;else if(o)return e.pos=d,-1}return n&&(a=e.pos),e.pos=d,a}},"./node_modules/markdown-it/lib/helpers/parse_link_title.js":function(e,r,o){var l=o("./node_modules/markdown-it/lib/common/utils.js").unescapeAll;e.exports=function(e,r,o){var t,n,s=0,i=r,a={ok:!1,pos:0,lines:0,str:""};if(o<=r)return a;if(34!==(n=e.charCodeAt(r))&&39!==n&&40!==n)return a;for(r++,40===n&&(n=41);r<o;){if((t=e.charCodeAt(r))===n)return a.pos=r+1,a.lines=s,a.str=l(e.slice(i+1,r)),a.ok=!0,a;10===t?s++:92===t&&r+1<o&&(r++,10===e.charCodeAt(r)&&s++),r++}return a}}}]);
(window.webpackJsonp=window.webpackJsonp||[]).push([[0],{"./CTFd/themes/core/assets/js/helpers.js":function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var d=r(n("./node_modules/jquery/dist/jquery.js")),p=r(n("./CTFd/themes/core/assets/js/ezq.js")),o=n("./CTFd/themes/core/assets/js/utils.js");function r(e){return e&&e.__esModule?e:{default:e}}function s(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function f(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=[],o=!0,r=!1,i=void 0;try{for(var s,a=e[Symbol.iterator]();!(o=(s=a.next()).done)&&(n.push(s.value),!t||n.length!==t);o=!0);}catch(e){r=!0,i=e}finally{try{o||null==a.return||a.return()}finally{if(r)throw i}}return n}(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance")}()}var i={files:{upload:function(t,e,n){var o=window.CTFd;t instanceof d.default&&(t=t[0]);var r=new FormData(t);r.append("nonce",o.config.csrfNonce);for(var i=0,s=Object.entries(e);i<s.length;i++){var a=f(s[i],2),l=a[0],c=a[1];r.append(l,c)}var u=p.default.ezProgressBar({width:0,title:"Upload Progress"});d.default.ajax({url:o.config.urlRoot+"/api/v1/files",data:r,type:"POST",cache:!1,contentType:!1,processData:!1,xhr:function(){var e=d.default.ajaxSettings.xhr();return e.upload.onprogress=function(e){if(e.lengthComputable){var t=e.loaded/e.total*100;u=p.default.ezProgressBar({target:u,width:t})}},e},success:function(e){t.reset(),u=p.default.ezProgressBar({target:u,width:100}),setTimeout(function(){u.modal("hide")},500),n&&n(e)}})}},comments:{get_comments:function(e){return window.CTFd.fetch("/api/v1/comments?"+d.default.param(e),{method:"GET",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()})},add_comment:function(e,t,n,o){var r=window.CTFd,i=function(t){for(var e=1;e<arguments.length;e++)if(e%2){var n=null!=arguments[e]?arguments[e]:{},o=Object.keys(n);"function"==typeof Object.getOwnPropertySymbols&&(o=o.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),o.forEach(function(e){s(t,e,n[e])})}else Object.defineProperties(t,Object.getOwnPropertyDescriptors(arguments[e]));return t}({content:e,type:t},n);r.fetch("/api/v1/comments",{method:"POST",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"},body:JSON.stringify(i)}).then(function(e){return e.json()}).then(function(e){o&&o(e)})},delete_comment:function(e){return window.CTFd.fetch("/api/v1/comments/".concat(e),{method:"DELETE",credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then(function(e){return e.json()})}},utils:{htmlEntities:o.htmlEntities,colorHash:o.colorHash,copyToClipboard:o.copyToClipboard},ezq:p.default};t.default=i},"./node_modules/markdown-it/lib/helpers/index.js":function(e,t,n){t.parseLinkLabel=n("./node_modules/markdown-it/lib/helpers/parse_link_label.js"),t.parseLinkDestination=n("./node_modules/markdown-it/lib/helpers/parse_link_destination.js"),t.parseLinkTitle=n("./node_modules/markdown-it/lib/helpers/parse_link_title.js")},"./node_modules/markdown-it/lib/helpers/parse_link_destination.js":function(e,t,n){var a=n("./node_modules/markdown-it/lib/common/utils.js").unescapeAll;e.exports=function(e,t,n){var o,r,i=t,s={ok:!1,pos:0,lines:0,str:""};if(60===e.charCodeAt(t)){for(t++;t<n;){if(10===(o=e.charCodeAt(t)))return s;if(62===o)return s.pos=t+1,s.str=a(e.slice(i+1,t)),s.ok=!0,s;92===o&&t+1<n?t+=2:t++}return s}for(r=0;t<n&&32!==(o=e.charCodeAt(t))&&!(o<32||127===o);)if(92===o&&t+1<n)t+=2;else{if(40===o&&r++,41===o){if(0===r)break;r--}t++}return i===t||0!==r||(s.str=a(e.slice(i,t)),s.lines=0,s.pos=t,s.ok=!0),s}},"./node_modules/markdown-it/lib/helpers/parse_link_label.js":function(e,t,n){e.exports=function(e,t,n){var o,r,i,s,a=-1,l=e.posMax,c=e.pos;for(e.pos=t+1,o=1;e.pos<l;){if(93===(i=e.src.charCodeAt(e.pos))&&0===--o){r=!0;break}if(s=e.pos,e.md.inline.skipToken(e),91===i)if(s===e.pos-1)o++;else if(n)return e.pos=c,-1}return r&&(a=e.pos),e.pos=c,a}},"./node_modules/markdown-it/lib/helpers/parse_link_title.js":function(e,t,n){var l=n("./node_modules/markdown-it/lib/common/utils.js").unescapeAll;e.exports=function(e,t,n){var o,r,i=0,s=t,a={ok:!1,pos:0,lines:0,str:""};if(n<=t)return a;if(34!==(r=e.charCodeAt(t))&&39!==r&&40!==r)return a;for(t++,40===r&&(r=41);t<n;){if((o=e.charCodeAt(t))===r)return a.pos=t+1,a.lines=i,a.str=l(e.slice(s+1,t)),a.ok=!0,a;10===o?i++:92===o&&t+1<n&&(t++,10===e.charCodeAt(t)&&i++),t++}return a}}}]);

View File

@@ -6,9 +6,9 @@ from CTFd.utils import markdown
from CTFd.utils.security.sanitize import sanitize_html
def build_html(html):
def build_html(html, sanitize=False):
html = markdown(html)
if current_app.config["HTML_SANITIZATION"] is True:
if current_app.config["HTML_SANITIZATION"] is True or sanitize is True:
html = sanitize_html(html)
return html

View File

@@ -0,0 +1,108 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from CTFd.models import Comments
from tests.helpers import (
create_ctfd,
destroy_ctfd,
gen_challenge,
gen_comment,
login_as_user,
register_user,
)
def test_api_post_comments():
app = create_ctfd()
with app.app_context():
gen_challenge(app.db)
with login_as_user(app, "admin") as admin:
r = admin.post(
"/api/v1/comments",
json={
"content": "this is a challenge comment",
"type": "challenge",
"challenge_id": 1,
},
)
# Check that POST response has comment data
assert r.status_code == 200
resp = r.get_json()
assert resp["data"]["content"] == "this is a challenge comment"
assert "this is a challenge comment" in resp["data"]["html"]
assert resp["data"]["type"] == "challenge"
# Check that the comment shows up in the list of comments for the given challenge
r = admin.get("/api/v1/comments?challenge_id=1", json="")
assert r.status_code == 200
resp = r.get_json()
assert resp["data"][0]["content"] == "this is a challenge comment"
assert "this is a challenge comment" in resp["data"][0]["html"]
assert resp["data"][0]["type"] == "challenge"
destroy_ctfd(app)
def test_api_post_comments_with_invalid_author_id():
app = create_ctfd()
with app.app_context():
gen_challenge(app.db)
register_user(app)
with login_as_user(app, "admin") as admin:
r = admin.post(
"/api/v1/comments",
json={
"content": "this is a challenge comment",
"type": "challenge",
"challenge_id": 1,
"author_id": 2,
},
)
# Check that POST response has comment data
assert r.status_code == 200
resp = r.get_json()
assert resp["data"]["author_id"] == 1
destroy_ctfd(app)
def test_api_get_comments():
app = create_ctfd()
with app.app_context():
gen_challenge(app.db)
with login_as_user(app, "admin") as admin:
gen_comment(
app.db,
content="this is a challenge comment",
author_id=1,
challenge_id=1,
)
r = admin.get("/api/v1/comments", json="")
# Check that the comment shows up in the list of all comments
assert r.status_code == 200
resp = r.get_json()
assert resp["data"][0]["content"] == "this is a challenge comment"
assert "this is a challenge comment" in resp["data"][0]["html"]
assert resp["data"][0]["type"] == "challenge"
destroy_ctfd(app)
def test_api_delete_comments():
app = create_ctfd()
with app.app_context():
gen_challenge(app.db)
with login_as_user(app, "admin") as admin:
gen_comment(
app.db,
content="this is a challenge comment",
author_id=1,
challenge_id=1,
)
assert Comments.query.count() == 1
# Check that the comment can be deleted
r = admin.delete("/api/v1/comments/1", json="")
assert r.status_code == 200
resp = r.get_json()
assert Comments.query.count() == 0
assert resp["success"] is True
destroy_ctfd(app)

View File

@@ -17,21 +17,26 @@ from CTFd.cache import cache, clear_standings
from CTFd.config import TestingConfig
from CTFd.models import (
Awards,
ChallengeComments,
ChallengeFiles,
Challenges,
Comments,
Fails,
Files,
Flags,
Hints,
Notifications,
PageComments,
PageFiles,
Pages,
Solves,
Tags,
TeamComments,
Teams,
Tokens,
Tracking,
Unlocks,
UserComments,
Users,
)
@@ -435,6 +440,24 @@ def gen_token(db, type="user", user_id=None, expiration=None):
return token
def gen_comment(db, content="comment", author_id=None, type="challenge", **kwargs):
if type == "challenge":
model = ChallengeComments
elif type == "user":
model = UserComments
elif type == "team":
model = TeamComments
elif type == "page":
model = PageComments
else:
model = Comments
comment = model(content=content, author_id=author_id, type=type, **kwargs)
db.session.add(comment)
db.session.commit()
return comment
def simulate_user_activity(db, user):
gen_tracking(db, user_id=user.id)
gen_award(db, user_id=user.id)

View File

@@ -65,6 +65,7 @@ function getJSConfig(root, type, entries, mode) {
return {
entry: out,
mode: mode,
output: {
path: path.resolve(__dirname, 'CTFd', root, 'static', type),
publicPath: '/' + root + '/static/' + type,
@@ -147,7 +148,16 @@ function getJSConfig(root, type, entries, mode) {
},
cacheBusting: true,
},
}
},
// This rule is magically used to load the <style> section of VueJS SFC.
// Don't really understand what magic Vue is using here but it works.
{
test: /\.css$/,
use: [
'vue-style-loader',
'css-loader'
]
},
],
},
plugins: [
@@ -184,6 +194,7 @@ function getCSSConfig(root, type, entries, mode) {
return {
entry: out,
mode: mode,
output: {
path: path.resolve(__dirname, 'CTFd', root, 'static', type),
publicPath: '/' + root + '/static/' + type,
@@ -208,13 +219,6 @@ function getCSSConfig(root, type, entries, mode) {
}
]
},
// {
// test: /\.css$/,
// use: [
// 'vue-style-loader',
// 'css-loader'
// ]
// },
{
test: /\.(s?)css$/,
use: [