Repository: CTFd/CTFd
Pull request #425 (2017-10-24 23:55:00)

Custom challenge tables
This allows challenge types to have custom tables. In addition, the challenge type plugins now have a static interface which needs to be implemented by every challenge type plugin. 

This allows people to create, read, update, and delete custom challenge types. 


Patch:
diff --git a/CTFd/admin/challenges.py b/CTFd/admin/challenges.py
index 146d2759..74ad5617 100644
--- a/CTFd/admin/challenges.py
+++ b/CTFd/admin/challenges.py
@@ -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'))
diff --git a/CTFd/models.py b/CTFd/models.py
index ffb8ef72..7121b65b 100644
--- a/CTFd/models.py
+++ b/CTFd/models.py
@@ -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
diff --git a/CTFd/plugins/challenges/__init__.py b/CTFd/plugins/challenges/__init__.py
index 0ee477da..055a2ae6 100644
--- a/CTFd/plugins/challenges/__init__.py
+++ b/CTFd/plugins/challenges/__init__.py
@@ -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):
         """
diff --git a/CTFd/plugins/challenges/assets/standard-challenge-update.js b/CTFd/plugins/challenges/assets/standard-challenge-update.js
index 265b8b1d..253b46f4 100644
--- a/CTFd/plugins/challenges/assets/standard-challenge-update.js
+++ b/CTFd/plugins/challenges/assets/standard-challenge-update.js
@@ -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();
-    }
-    $('.chal-category').val(obj.category);
-    $('.chal-id').val(obj.id);
-    $('.chal-hidden').prop('checked', false);
-    if (obj.hidden) {
-        $('.chal-hidden').prop('checked', true);
+    $.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();
     }
-    //$('#update-challenge .chal-delete').attr({
-    //    'href': '/admin/chal/close/' + (id + 1)
-    //})
-    if (typeof update === 'undefined')
-        $('#update-challenge').modal();
 }
 
 function openchal(id){
diff --git a/CTFd/themes/admin/static/js/chalboard.js b/CTFd/themes/admin/static/js/chalboard.js
index f7a28390..2664cc02 100644
--- a/CTFd/themes/admin/static/js/chalboard.js
+++ b/CTFd/themes/admin/static/js/chalboard.js
@@ -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);
         };


Test patch:
diff --git a/tests/admin/test_admin_facing.py b/tests/admin/test_admin_facing.py
index d306dec6..18221ea5 100644
--- a/tests/admin/test_admin_facing.py
+++ b/tests/admin/test_admin_facing.py
@@ -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)