Custom challenge tables (#425)

* Allows Challenges to have custom tables which allow them to have custom behavior 
* Adding create, update, delete staticmethods giving Challenge Types a static interface to implement
selenium-screenshot-testing
Kevin Chung 2017-10-24 21:06:56 -04:00 committed by GitHub
parent cdc7a70fe7
commit 710ce6d500
6 changed files with 276 additions and 86 deletions

View File

@ -73,6 +73,18 @@ def admin_chals():
return render_template('admin/chals.html')
@admin_challenges.route('/admin/chals/<int:chalid>', methods=['GET', 'POST'])
@admins_only
def admin_chal_detail(chalid):
if request.method == 'POST':
pass
elif request.method == 'GET':
chal = Challenges.query.filter_by(id=chalid).first_or_404()
chal_class = get_chal_class(chal.type)
obj, data = chal_class.read(chal)
return jsonify(data)
@admin_challenges.route('/admin/tags/<int:chalid>', methods=['GET', 'POST'])
@admins_only
def admin_tags(chalid):
@ -236,41 +248,9 @@ def admin_get_values(chalid, prop):
@admins_only
def admin_create_chal():
if request.method == 'POST':
files = request.files.getlist('files[]')
# Create challenge
chal = Challenges(
name=request.form['name'],
description=request.form['desc'],
value=request.form['value'],
category=request.form['category'],
type=request.form['chaltype']
)
if 'hidden' in request.form:
chal.hidden = True
else:
chal.hidden = False
max_attempts = request.form.get('max_attempts')
if max_attempts and max_attempts.isdigit():
chal.max_attempts = int(max_attempts)
db.session.add(chal)
db.session.flush()
flag = Keys(chal.id, request.form['key'], request.form['key_type[0]'])
if request.form.get('keydata'):
flag.data = request.form.get('keydata')
db.session.add(flag)
db.session.commit()
for f in files:
utils.upload_file(file=f, chalid=chal.id)
db.session.commit()
db.session.close()
chal_type = request.form['chaltype']
chal_class = get_chal_class(chal_type)
chal_class.create(request)
return redirect(url_for('admin_challenges.admin_chals'))
else:
return render_template('admin/chals/create.html')
@ -280,17 +260,8 @@ def admin_create_chal():
@admins_only
def admin_delete_chal():
challenge = Challenges.query.filter_by(id=request.form['id']).first_or_404()
WrongKeys.query.filter_by(chalid=challenge.id).delete()
Solves.query.filter_by(chalid=challenge.id).delete()
Keys.query.filter_by(chal=challenge.id).delete()
files = Files.query.filter_by(chal=challenge.id).all()
for f in files:
utils.delete_file(f.id)
Files.query.filter_by(chal=challenge.id).delete()
Tags.query.filter_by(chal=challenge.id).delete()
Challenges.query.filter_by(id=challenge.id).delete()
db.session.commit()
db.session.close()
chal_class = get_chal_class(challenge.type)
chal_class.delete(challenge)
return '1'
@ -298,13 +269,6 @@ def admin_delete_chal():
@admins_only
def admin_update_chal():
challenge = Challenges.query.filter_by(id=request.form['id']).first_or_404()
challenge.name = request.form['name']
challenge.description = request.form['desc']
challenge.value = int(request.form.get('value', 0)) if request.form.get('value', 0) else 0
challenge.max_attempts = int(request.form.get('max_attempts', 0)) if request.form.get('max_attempts', 0) else 0
challenge.category = request.form['category']
challenge.hidden = 'hidden' in request.form
db.session.add(challenge)
db.session.commit()
db.session.close()
chal_class = get_chal_class(challenge.type)
chal_class.update(challenge, request)
return redirect(url_for('admin_challenges.admin_chals'))

View File

@ -50,6 +50,10 @@ class Challenges(db.Model):
category = db.Column(db.String(80))
type = db.Column(db.String(80))
hidden = db.Column(db.Boolean)
__mapper_args__ = {
'polymorphic_identity': 'standard',
'polymorphic_on': type
}
def __init__(self, name, description, value, category, type='standard'):
self.name = name

View File

@ -1,6 +1,6 @@
from CTFd.plugins import register_plugin_assets_directory
from CTFd.plugins.keys import get_key_class
from CTFd.models import db, Solves, WrongKeys, Keys
from CTFd.models import db, Solves, WrongKeys, Keys, Challenges, Files, Tags
from CTFd import utils
@ -25,6 +25,113 @@ class CTFdStandardChallenge(BaseChallenge):
'modal': '/plugins/challenges/assets/standard-challenge-modal.js',
}
@staticmethod
def create(request):
"""
This method is used to process the challenge creation request.
:param request:
:return:
"""
files = request.files.getlist('files[]')
# Create challenge
chal = Challenges(
name=request.form['name'],
description=request.form['desc'],
value=request.form['value'],
category=request.form['category'],
type=request.form['chaltype']
)
if 'hidden' in request.form:
chal.hidden = True
else:
chal.hidden = False
max_attempts = request.form.get('max_attempts')
if max_attempts and max_attempts.isdigit():
chal.max_attempts = int(max_attempts)
db.session.add(chal)
db.session.commit()
flag = Keys(chal.id, request.form['key'], request.form['key_type[0]'])
if request.form.get('keydata'):
flag.data = request.form.get('keydata')
db.session.add(flag)
db.session.commit()
for f in files:
utils.upload_file(file=f, chalid=chal.id)
db.session.commit()
@staticmethod
def read(challenge):
"""
This method is in used to access the data of a challenge in a format processable by the front end.
:param challenge:
:return: Challenge object, data dictionary to be returned to the user
"""
data = {
'id': challenge.id,
'name': challenge.name,
'value': challenge.value,
'description': challenge.description,
'category': challenge.category,
'hidden': challenge.hidden,
'max_attempts': challenge.max_attempts,
'type': challenge.type,
'type_data': {
'id': CTFdStandardChallenge.id,
'name': CTFdStandardChallenge.name,
'templates': CTFdStandardChallenge.templates,
'scripts': CTFdStandardChallenge.scripts,
}
}
return challenge, data
@staticmethod
def update(challenge, request):
"""
This method is used to update the information associated with a challenge. This should be kept strictly to the
Challenges table and any child tables.
:param challenge:
:param request:
:return:
"""
challenge.name = request.form['name']
challenge.description = request.form['desc']
challenge.value = int(request.form.get('value', 0)) if request.form.get('value', 0) else 0
challenge.max_attempts = int(request.form.get('max_attempts', 0)) if request.form.get('max_attempts', 0) else 0
challenge.category = request.form['category']
challenge.hidden = 'hidden' in request.form
db.session.commit()
db.session.close()
@staticmethod
def delete(challenge):
"""
This method is used to delete the resources used by a challenge.
:param challenge:
:return:
"""
WrongKeys.query.filter_by(chalid=challenge.id).delete()
Solves.query.filter_by(chalid=challenge.id).delete()
Keys.query.filter_by(chal=challenge.id).delete()
files = Files.query.filter_by(chal=challenge.id).all()
for f in files:
utils.delete_file(f.id)
Files.query.filter_by(chal=challenge.id).delete()
Tags.query.filter_by(chal=challenge.id).delete()
Challenges.query.filter_by(id=challenge.id).delete()
db.session.commit()
@staticmethod
def attempt(chal, request):
"""

View File

@ -354,32 +354,29 @@ $('#hint-modal-submit').submit(function (e) {
});
function loadchal(id, update) {
// $('#chal *').show()
// $('#chal > h1').hide()
obj = $.grep(challenges['game'], function (e) {
return e.id == id;
})[0]
$('#desc-write-link').click() // Switch to Write tab
$('.chal-title').text(obj.name);
$('.chal-name').val(obj.name);
$('.chal-desc').val(obj.description);
$('.chal-value').val(obj.value);
if (parseInt(obj.max_attempts) > 0){
$('.chal-attempts').val(obj.max_attempts);
$('#limit_max_attempts').prop('checked', true);
$('#chal-attempts-group').show();
$.get(script_root + '/admin/chals/' + id, function(obj){
$('#desc-write-link').click() // Switch to Write tab
$('.chal-title').text(obj.name);
$('.chal-name').val(obj.name);
$('.chal-desc').val(obj.description);
$('.chal-value').val(obj.value);
if (parseInt(obj.max_attempts) > 0){
$('.chal-attempts').val(obj.max_attempts);
$('#limit_max_attempts').prop('checked', true);
$('#chal-attempts-group').show();
}
$('.chal-category').val(obj.category);
$('.chal-id').val(obj.id);
$('.chal-hidden').prop('checked', false);
if (obj.hidden) {
$('.chal-hidden').prop('checked', true);
}
//$('#update-challenge .chal-delete').attr({
// 'href': '/admin/chal/close/' + (id + 1)
//})
if (typeof update === 'undefined')
$('#update-challenge').modal();
}
$('.chal-category').val(obj.category);
$('.chal-id').val(obj.id);
$('.chal-hidden').prop('checked', false);
if (obj.hidden) {
$('.chal-hidden').prop('checked', true);
}
//$('#update-challenge .chal-delete').attr({
// 'href': '/admin/chal/close/' + (id + 1)
//})
if (typeof update === 'undefined')
$('#update-challenge').modal();
}
function openchal(id){

View File

@ -40,9 +40,9 @@ function load_edit_key_modal(key_id, key_type_name) {
}
function load_chal_template(id, success_cb){
obj = $.grep(challenges['game'], function (e) {
var obj = $.grep(challenges['game'], function (e) {
return e.id == id;
})[0]
})[0];
$.get(script_root + obj.type_data.templates.update, function(template_data){
var template = Handlebars.compile(template_data);
$("#update-modals-entry-div").html(template({'nonce':$('#nonce').val(), 'script_root':script_root}));
@ -50,7 +50,7 @@ function load_chal_template(id, success_cb){
url: script_root + obj.type_data.scripts.update,
dataType: "script",
success: success_cb,
cache: true,
cache: false,
});
});
}
@ -72,7 +72,7 @@ function loadchals(){
};
for (var i = 0; i <= challenges['game'].length - 1; i++) {
var chal = challenges['game'][i]
var chal = challenges['game'][i];
var chal_button = $('<button class="chal-button col-md-2 theme-background" value="{0}"><h5>{1}</h5><p class="chal-points">{2}</p><span class="chal-percent">{3}% solved</span></button>'.format(chal.id, chal.name, chal.value, Math.round(chal.percentage_solved * 100)));
$('#' + challenges['game'][i].category.replace(/ /g,"-").hashCode()).append(chal_button);
};

View File

@ -1,9 +1,15 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from tests.helpers import *
from CTFd.models import Teams
from CTFd.models import Teams, Challenges
from CTFd.utils import get_config, set_config, override_template, sendmail, verify_email, ctf_started, ctf_ended
from CTFd.plugins.challenges import get_chal_class
from freezegun import freeze_time
from mock import patch
import json
def test_admin_panel():
"""Does the admin panel return a 200 by default"""
@ -103,3 +109,115 @@ def test_admins_can_access_challenges_before_ctftime():
solve_count = app.db.session.query(app.db.func.count(Solves.id)).first()[0]
assert solve_count == 1
destroy_ctfd(app)
def test_admins_can_create_challenges():
'''Test that admins can create new challenges'''
app = create_ctfd()
with app.app_context():
client = login_as_user(app, name="admin", password="password")
with client.session_transaction() as sess:
data = {
'name': '💫',
'category': '💫',
'desc': 'description',
'value': 100,
'key_type[0]': 'static',
'max_attempts': '',
'nonce': sess.get('nonce'),
'chaltype': 'standard'
}
r = client.post('/admin/chal/new', data=data)
assert Challenges.query.count() == 1
destroy_ctfd(app)
def test_admins_can_update_challenges():
'''Test that admins can update challenges'''
app = create_ctfd()
with app.app_context():
client = login_as_user(app, name="admin", password="password")
chal = gen_challenge(app.db)
chal_id = chal.id
assert Challenges.query.count() == 1
with client.session_transaction() as sess:
data = {
'id': chal_id,
'name': '💫',
'category': '💫',
'desc': 'description',
'value': 100,
'key_type[0]': 'static',
'max_attempts': '',
'nonce': sess.get('nonce'),
'chaltype': 'standard'
}
r = client.post('/admin/chal/update', data=data)
assert Challenges.query.count() == 1
chal_check = Challenges.query.filter_by(id=chal_id).first()
assert chal_check.name == '💫'
destroy_ctfd(app)
def test_admins_can_delete_challenges():
'''Test that admins can delete challenges'''
app = create_ctfd()
with app.app_context():
client = login_as_user(app, name="admin", password="password")
chal = gen_challenge(app.db)
chal_id = chal.id
assert Challenges.query.count() == 1
with client.session_transaction() as sess:
data = {
'id': chal_id,
'nonce': sess.get('nonce'),
}
r = client.post('/admin/chal/delete', data=data)
assert r.get_data(as_text=True) == '1'
assert Challenges.query.count() == 0
destroy_ctfd(app)
def test_admin_chal_detail_returns_proper_data():
"""Test that the /admin/chals/<int:chalid> endpoint returns the proper data"""
app = create_ctfd()
with app.app_context():
client = login_as_user(app, name="admin", password="password")
chal = gen_challenge(app.db)
chal_class = get_chal_class(chal.type)
data = {
'id': chal.id,
'name': chal.name,
'value': chal.value,
'description': chal.description,
'category': chal.category,
'hidden': chal.hidden,
'max_attempts': chal.max_attempts,
'type': chal.type,
'type_data': {
'id': chal_class.id,
'name': chal_class.name,
'templates': chal_class.templates,
'scripts': chal_class.scripts,
}
}
assert Challenges.query.count() == 1
r = client.get('/admin/chals/1')
response = json.loads(r.get_data(as_text=True))
assert data == response
destroy_ctfd(app)