Skip to content

Commit a113d02

Browse files
authored
Add Challenge Import (hugsy#68)
* Add Challenge Import * Fix duplicate import if challenge exists * Factorize form.error into snippet * Revert docker-entrypoint.sh * Extend Challenge Import with RAW import * Extend Challenge Import with rCTF import * Compliance changes
1 parent 6914cf8 commit a113d02

File tree

8 files changed

+265
-9
lines changed

8 files changed

+265
-9
lines changed

ctfpad/forms.py

+59
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
from django.contrib.auth.forms import UserChangeForm
23
from django.contrib.auth.models import User
34

@@ -136,6 +137,64 @@ def cleaned_tags(self):
136137
return data
137138

138139

140+
class ChallengeImportForm(forms.Form):
141+
FORMAT_CHOICES = (
142+
("RAW", "RAW"),
143+
("CTFd", "CTFd"),
144+
("rCTF", "rCTF"),
145+
)
146+
format = forms.ChoiceField(choices=FORMAT_CHOICES, initial='CTFd')
147+
data = forms.CharField(widget=forms.Textarea)
148+
149+
def clean_data(self):
150+
data = self.cleaned_data['data']
151+
152+
# Choose the cleaning method based on the format field.
153+
if self.cleaned_data['format'] == 'RAW':
154+
return self._clean_raw_data(data)
155+
elif self.cleaned_data['format'] == 'CTFd':
156+
return self._clean_ctfd_data(data)
157+
elif self.cleaned_data['format'] == 'rCTF':
158+
return self._clean_rctf_data(data)
159+
else:
160+
raise forms.ValidationError('Invalid data format.')
161+
162+
@staticmethod
163+
def _clean_raw_data(data):
164+
challenges = []
165+
for line in data.splitlines():
166+
parts = line.split('|')
167+
if len(parts) != 2:
168+
raise forms.ValidationError('RAW data line does not have exactly two parts.')
169+
challenges.append({
170+
'name': parts[0].strip(),
171+
'category': parts[1].strip(),
172+
})
173+
return challenges
174+
175+
@staticmethod
176+
def _clean_ctfd_data(data):
177+
try:
178+
json_data = json.loads(data)
179+
if not json_data.get('success') or 'data' not in json_data:
180+
raise ValidationError('Invalid JSON format. Please provide valid CTFd JSON data.')
181+
except json.JSONDecodeError:
182+
raise ValidationError('Invalid JSON format. Please provide valid CTFd JSON data.')
183+
184+
return json_data["data"]
185+
186+
@staticmethod
187+
def _clean_rctf_data(data):
188+
try:
189+
json_data = json.loads(data)
190+
if "successful" not in json_data.get('message') or 'data' not in json_data:
191+
raise ValidationError('Invalid JSON format. Please provide valid rCTF JSON data.')
192+
except json.JSONDecodeError:
193+
raise ValidationError('Invalid JSON format. Please provide valid rCTF JSON data.')
194+
195+
return json_data["data"]
196+
197+
139198
class ChallengeSetFlagForm(ChallengeUpdateForm):
140199
class Meta:
141200
model = Challenge

ctfpad/models.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -637,7 +637,7 @@ def files(self):
637637
def jitsi_url(self):
638638
return f"{JITSI_URL}/{self.ctf.id}--{self.id}"
639639

640-
def save(self):
640+
def save(self, **kwargs):
641641
if self.flag_tracker.has_changed("flag"):
642642
self.status = "solved" if self.flag else "unsolved"
643643
self.solvers.add(self.last_update_by)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
{% extends 'ctfpad/main.html' %}
2+
3+
{% block content %}
4+
<br/>
5+
6+
{% include 'snippets/formerror.html' %}
7+
8+
<div class="row">
9+
<div class="col-lg-4 offset-lg-4">
10+
11+
{% for message in messages %}
12+
<p id="messages">{{ message }}</p>
13+
{% endfor %}
14+
15+
<div class="card">
16+
<div class="card-header">
17+
<h5 class="card-title">
18+
<p class="card-header-title">Import Challenges</p>
19+
</h5>
20+
</div>
21+
22+
<div class="card-body">
23+
<form class="form" method="post">
24+
{% csrf_token %}
25+
<div class="modal-body">
26+
<div class="form-group">
27+
<input type="hidden" id="{{ form.ctf.id_for_label }}" name="{{ form.ctf.html_name }}"
28+
value="{{ form.ctf.value }}"/>
29+
30+
<label for="{{ form.format.id_for_label }}"
31+
class="label"><strong>Format</strong></label>
32+
<div class="input-group mb-3">
33+
<select id="{{ form.format.id_for_label }}" name="{{ form.format.html_name }}"
34+
class="form-control">
35+
{% for choice in form.format.field.choices %}
36+
<option value="{{ choice.0 }}">{{ choice.1 }}</option>
37+
{% endfor %}
38+
</select>
39+
</div>
40+
41+
<label for="{{ form.data.id_for_label }}" class="label"><strong>Data</strong></label>
42+
<div class="input-group mb-3">
43+
<div class="input-group-append">
44+
<span class="input-group-text">
45+
<i class="fas fa-file-import"></i>
46+
</span>
47+
</div>
48+
<textarea id="{{ form.data.id_for_label }}"
49+
name="{{ form.data.html_name }}"
50+
placeholder="Data"
51+
class="form-control"
52+
required>{% if form.data.value %}
53+
{{ form.data.value }}{% endif %}</textarea>
54+
</div>
55+
</div>
56+
57+
<div class="card-footer text-muted">
58+
<div class="control card-footer-item">
59+
<button type="button" class="btn-primary btn-sm btn-block"
60+
onclick="this.form.submit();">Import Challenges
61+
</button>
62+
<button type="button" class="btn btn-secondary btn-sm btn-block"
63+
onclick="window.history.back();">Cancel
64+
</button>
65+
</div>
66+
</div>
67+
</form>
68+
</div>
69+
</div>
70+
</div>
71+
</div>
72+
<script>
73+
window.addEventListener('DOMContentLoaded', (event) => {
74+
var formatSelect = document.getElementById('{{ form.format.id_for_label }}');
75+
var dataTextArea = document.getElementById('{{ form.data.id_for_label }}');
76+
77+
formatSelect.addEventListener('change', function () {
78+
var selectedFormat = this.value;
79+
var placeholderText;
80+
81+
switch (selectedFormat) {
82+
case 'RAW':
83+
placeholderText = 'name | category';
84+
break;
85+
case 'CTFd':
86+
placeholderText = 'paste CTFd JSON /api/v1/challenges';
87+
break;
88+
case 'rCTF':
89+
placeholderText = 'paste rCTF JSON /api/v1/challs';
90+
break;
91+
default:
92+
placeholderText = '';
93+
}
94+
95+
dataTextArea.setAttribute('placeholder', placeholderText);
96+
});
97+
98+
// Trigger the change event on page load to set initial placeholder
99+
formatSelect.dispatchEvent(new Event('change'));
100+
});
101+
</script>
102+
103+
104+
{% endblock %}

ctfpad/templates/ctfpad/ctfs/detail_challenges.html

+7-1
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,18 @@
88
<div class="row">
99
<div class="col-6">
1010
<div class="row mb-3">
11-
<div class="col-6">
11+
<div class="col-3">
1212
<a class="btn btn-success btn-sm btn-block" href="{% url 'ctfpad:challenges-create' ctf=ctf.id %}">
1313
<strong>New challenge</strong>
1414
</a>
1515
</div>
1616

17+
<div class="col-3">
18+
<a class="btn btn-success btn-sm btn-block" href="{% url 'ctfpad:challenges-import' ctf=ctf.id %}">
19+
<strong>Import challenges</strong>
20+
</a>
21+
</div>
22+
1723
<div class="col-3">
1824
<a class="btn btn-primary btn-sm btn-block" title="Add a category" data-toggle="modal" data-target="#QuickAddCategoryModal" href="#">
1925
<strong><i class="fas fa-folder-open" ></i></strong>

ctfpad/templatetags/ctfpad_filters.py

+6-3
Original file line numberDiff line numberDiff line change
@@ -7,28 +7,31 @@
77

88
register = template.Library()
99

10+
1011
@register.filter
1112
def as_local_datetime_for_member(naive_utc, member):
1213
aware_utc = pytz.utc.localize(naive_utc)
1314
member_tz = pytz.timezone(member.timezone)
1415
return aware_utc.astimezone(member_tz)
1516

17+
1618
@register.simple_tag
1719
def best_category(member, year=None):
1820
return member.best_category(year)
1921

22+
2023
@register.filter
2124
def as_time_accumulator_graph(items):
2225
Point = namedtuple("Point", "time accu")
2326
accu = 0
2427
res = []
2528
for x in items:
2629
accu += x.points
27-
res.append( Point(x.solved_time, accu) )
30+
res.append(Point(x.solved_time, accu))
2831
return res
2932

3033

31-
@register.simple_tag(takes_context = True)
34+
@register.simple_tag(takes_context=True)
3235
def theme_cookie(context):
3336
request = context['request']
3437
value = request.COOKIES.get('theme', 'light')
@@ -62,4 +65,4 @@ def as_tick_or_cross(b):
6265
if b:
6366
return mark_safe("""<strong><i class="fas fa-check" style="color: green;"></i><strong>""")
6467
else:
65-
return mark_safe("""<strong><i class="fas fa-times" style="color: red;"></i><strong>""")
68+
return mark_safe("""<strong><i class="fas fa-times" style="color: red;"></i><strong>""")

ctfpad/urls.py

+1
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
# challenges
6161
path("challenges/", views.challenges.ChallengeListView.as_view(), name="challenges-list"),
6262
path("challenges/create/", views.challenges.ChallengeCreateView.as_view(), name="challenges-create"),
63+
path("challenges/import/<uuid:ctf>/", views.challenges.ChallengeImportView.as_view(), name="challenges-import"),
6364
path("challenges/create/<uuid:ctf>/", views.challenges.ChallengeCreateView.as_view(), name="challenges-create"),
6465
path("challenges/<uuid:pk>/", views.challenges.ChallengeDetailView.as_view(), name="challenges-detail"),
6566
path("challenges/<uuid:pk>/edit/", views.challenges.ChallengeUpdateView.as_view(), name="challenges-edit"),

ctfpad/views/challenges.py

+59-4
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
1+
import json
22

33
from django.http.request import HttpRequest
44
from django.http.response import HttpResponse
55
from ctfpad.decorators import only_if_authenticated_user
66
from django.contrib import messages
77
from django.shortcuts import render, get_object_or_404, redirect
8-
from django.views.generic import ListView, DetailView, UpdateView, DeleteView, CreateView
8+
from django.views.generic import ListView, DetailView, UpdateView, DeleteView, CreateView, FormView
99
from django.urls import reverse, reverse_lazy
1010
from django.contrib.auth.mixins import LoginRequiredMixin
1111
from django.contrib.messages.views import SuccessMessageMixin
@@ -15,8 +15,9 @@
1515
ChallengeUpdateForm,
1616
ChallengeSetFlagForm,
1717
ChallengeFileCreateForm,
18+
ChallengeImportForm,
1819
)
19-
from ctfpad.models import Challenge, Ctf
20+
from ctfpad.models import Challenge, Ctf, ChallengeCategory
2021
from ctftools.settings import HEDGEDOC_URL
2122

2223
from ctfpad.helpers import (
@@ -69,6 +70,60 @@ def get_success_url(self):
6970
return reverse("ctfpad:challenges-detail", kwargs={'pk': self.object.pk})
7071

7172

73+
class ChallengeImportView(LoginRequiredMixin, FormView):
74+
model = Challenge
75+
template_name = "ctfpad/challenges/import.html"
76+
login_url = "/users/login/"
77+
redirect_field_name = "redirect_to"
78+
form_class = ChallengeImportForm
79+
success_message = "Challenges were successfully imported!"
80+
81+
def get(self, request, *args, **kwargs):
82+
self.initial["ctf"] = self.kwargs.get("ctf")
83+
form = self.form_class(initial=self.initial)
84+
return render(request, self.template_name, {'form': form})
85+
86+
def form_valid(self, form):
87+
ctf_id = self.kwargs.get("ctf")
88+
ctf = Ctf.objects.get(pk=ctf_id)
89+
data = form.cleaned_data['data']
90+
91+
try:
92+
for challenge in data:
93+
category, created = ChallengeCategory.objects.get_or_create(name=challenge["category"].strip().lower())
94+
points = 0
95+
description = ""
96+
97+
if form.cleaned_data['format'] == 'CTFd':
98+
points = challenge.get("value")
99+
elif form.cleaned_data['format'] == 'rCTF':
100+
points = challenge.get("points")
101+
description = challenge.get("description")
102+
103+
defaults = {
104+
"name": challenge.get("name"),
105+
"points": points,
106+
"category": category,
107+
"description": description,
108+
"ctf": ctf,
109+
}
110+
111+
Challenge.objects.update_or_create(
112+
defaults=defaults,
113+
name=challenge.get("name"),
114+
ctf=ctf,
115+
)
116+
117+
messages.success(self.request, "Import successful!")
118+
return super().form_valid(form)
119+
except Exception as e:
120+
messages.error(self.request, f"Error: {str(e)}")
121+
return self.form_invalid(form)
122+
123+
def get_success_url(self):
124+
return reverse("ctfpad:ctfs-detail", kwargs={'pk': self.initial["ctf"]})
125+
126+
72127
class ChallengeDetailView(LoginRequiredMixin, DetailView):
73128
model = Challenge
74129
template_name = "ctfpad/challenges/detail.html"
@@ -101,7 +156,7 @@ def form_valid(self, form):
101156
if "solvers" in form.cleaned_data:
102157

103158
if (len(form.cleaned_data["solvers"]) > 0 and not form.cleaned_data["flag"]) or \
104-
(len(form.cleaned_data["solvers"]) == 0 and form.cleaned_data["flag"]):
159+
(len(form.cleaned_data["solvers"]) == 0 and form.cleaned_data["flag"]):
105160
messages.error(
106161
self.request, "Cannot set flag without solver(s)")
107162
return redirect("ctfpad:challenges-detail", self.object.id)

static/js/challenge.js

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
window.addEventListener('DOMContentLoaded', (event) => {
2+
var formatSelect = document.getElementById('id_format');
3+
var dataTextArea = document.getElementById('id_data');
4+
5+
formatSelect.addEventListener('change', function () {
6+
var selectedFormat = this.value;
7+
var placeholderText;
8+
9+
switch (selectedFormat) {
10+
case 'RAW':
11+
placeholderText = 'name | category';
12+
break;
13+
case 'CTFd':
14+
placeholderText = 'paste CTFd JSON /api/v1/challenges';
15+
break;
16+
case 'rCTF':
17+
placeholderText = 'paste rCTF JSON /api/v1/challs';
18+
break;
19+
default:
20+
placeholderText = '';
21+
}
22+
23+
dataTextArea.setAttribute('placeholder', placeholderText);
24+
});
25+
26+
// Trigger the change event on page load to set initial placeholder
27+
formatSelect.dispatchEvent(new Event('change'));
28+
});

0 commit comments

Comments
 (0)