Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Challenge Import #68

Merged
merged 5 commits into from
Jun 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions ctfpad/forms.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from django.contrib.auth.forms import UserChangeForm
from django.contrib.auth.models import User

Expand Down Expand Up @@ -136,6 +137,64 @@ def cleaned_tags(self):
return data


class ChallengeImportForm(forms.Form):
FORMAT_CHOICES = (
("RAW", "RAW"),
("CTFd", "CTFd"),
("rCTF", "rCTF"),
)
format = forms.ChoiceField(choices=FORMAT_CHOICES, initial='CTFd')
data = forms.CharField(widget=forms.Textarea)

def clean_data(self):
data = self.cleaned_data['data']

# Choose the cleaning method based on the format field.
if self.cleaned_data['format'] == 'RAW':
return self._clean_raw_data(data)
elif self.cleaned_data['format'] == 'CTFd':
return self._clean_ctfd_data(data)
elif self.cleaned_data['format'] == 'rCTF':
return self._clean_rctf_data(data)
else:
raise forms.ValidationError('Invalid data format.')

@staticmethod
def _clean_raw_data(data):
challenges = []
for line in data.splitlines():
parts = line.split('|')
if len(parts) != 2:
raise forms.ValidationError('RAW data line does not have exactly two parts.')
challenges.append({
'name': parts[0].strip(),
'category': parts[1].strip(),
})
return challenges

@staticmethod
def _clean_ctfd_data(data):
try:
json_data = json.loads(data)
if not json_data.get('success') or 'data' not in json_data:
raise ValidationError('Invalid JSON format. Please provide valid CTFd JSON data.')
except json.JSONDecodeError:
raise ValidationError('Invalid JSON format. Please provide valid CTFd JSON data.')

return json_data["data"]

@staticmethod
def _clean_rctf_data(data):
try:
json_data = json.loads(data)
if "successful" not in json_data.get('message') or 'data' not in json_data:
raise ValidationError('Invalid JSON format. Please provide valid rCTF JSON data.')
except json.JSONDecodeError:
raise ValidationError('Invalid JSON format. Please provide valid rCTF JSON data.')

return json_data["data"]


class ChallengeSetFlagForm(ChallengeUpdateForm):
class Meta:
model = Challenge
Expand Down
2 changes: 1 addition & 1 deletion ctfpad/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,7 @@ def files(self):
def jitsi_url(self):
return f"{JITSI_URL}/{self.ctf.id}--{self.id}"

def save(self):
def save(self, **kwargs):
if self.flag_tracker.has_changed("flag"):
self.status = "solved" if self.flag else "unsolved"
self.solvers.add(self.last_update_by)
Expand Down
104 changes: 104 additions & 0 deletions ctfpad/templates/ctfpad/challenges/import.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
{% extends 'ctfpad/main.html' %}

{% block content %}
<br/>

{% include 'snippets/formerror.html' %}

<div class="row">
<div class="col-lg-4 offset-lg-4">

{% for message in messages %}
<p id="messages">{{ message }}</p>
{% endfor %}

<div class="card">
<div class="card-header">
<h5 class="card-title">
<p class="card-header-title">Import Challenges</p>
</h5>
</div>

<div class="card-body">
<form class="form" method="post">
{% csrf_token %}
<div class="modal-body">
<div class="form-group">
<input type="hidden" id="{{ form.ctf.id_for_label }}" name="{{ form.ctf.html_name }}"
value="{{ form.ctf.value }}"/>

<label for="{{ form.format.id_for_label }}"
class="label"><strong>Format</strong></label>
<div class="input-group mb-3">
<select id="{{ form.format.id_for_label }}" name="{{ form.format.html_name }}"
class="form-control">
{% for choice in form.format.field.choices %}
<option value="{{ choice.0 }}">{{ choice.1 }}</option>
{% endfor %}
</select>
</div>

<label for="{{ form.data.id_for_label }}" class="label"><strong>Data</strong></label>
<div class="input-group mb-3">
<div class="input-group-append">
<span class="input-group-text">
<i class="fas fa-file-import"></i>
</span>
</div>
<textarea id="{{ form.data.id_for_label }}"
name="{{ form.data.html_name }}"
placeholder="Data"
class="form-control"
required>{% if form.data.value %}
{{ form.data.value }}{% endif %}</textarea>
</div>
</div>

<div class="card-footer text-muted">
<div class="control card-footer-item">
<button type="button" class="btn-primary btn-sm btn-block"
onclick="this.form.submit();">Import Challenges
</button>
<button type="button" class="btn btn-secondary btn-sm btn-block"
onclick="window.history.back();">Cancel
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
window.addEventListener('DOMContentLoaded', (event) => {
var formatSelect = document.getElementById('{{ form.format.id_for_label }}');
var dataTextArea = document.getElementById('{{ form.data.id_for_label }}');

formatSelect.addEventListener('change', function () {
var selectedFormat = this.value;
var placeholderText;

switch (selectedFormat) {
case 'RAW':
placeholderText = 'name | category';
break;
case 'CTFd':
placeholderText = 'paste CTFd JSON /api/v1/challenges';
break;
case 'rCTF':
placeholderText = 'paste rCTF JSON /api/v1/challs';
break;
default:
placeholderText = '';
}

dataTextArea.setAttribute('placeholder', placeholderText);
});

// Trigger the change event on page load to set initial placeholder
formatSelect.dispatchEvent(new Event('change'));
});
</script>


{% endblock %}
8 changes: 7 additions & 1 deletion ctfpad/templates/ctfpad/ctfs/detail_challenges.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,18 @@
<div class="row">
<div class="col-6">
<div class="row mb-3">
<div class="col-6">
<div class="col-3">
<a class="btn btn-success btn-sm btn-block" href="{% url 'ctfpad:challenges-create' ctf=ctf.id %}">
<strong>New challenge</strong>
</a>
</div>

<div class="col-3">
<a class="btn btn-success btn-sm btn-block" href="{% url 'ctfpad:challenges-import' ctf=ctf.id %}">
<strong>Import challenges</strong>
</a>
</div>

<div class="col-3">
<a class="btn btn-primary btn-sm btn-block" title="Add a category" data-toggle="modal" data-target="#QuickAddCategoryModal" href="#">
<strong><i class="fas fa-folder-open" ></i></strong>
Expand Down
9 changes: 6 additions & 3 deletions ctfpad/templatetags/ctfpad_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,31 @@

register = template.Library()


@register.filter
def as_local_datetime_for_member(naive_utc, member):
aware_utc = pytz.utc.localize(naive_utc)
member_tz = pytz.timezone(member.timezone)
return aware_utc.astimezone(member_tz)


@register.simple_tag
def best_category(member, year=None):
return member.best_category(year)


@register.filter
def as_time_accumulator_graph(items):
Point = namedtuple("Point", "time accu")
accu = 0
res = []
for x in items:
accu += x.points
res.append( Point(x.solved_time, accu) )
res.append(Point(x.solved_time, accu))
return res


@register.simple_tag(takes_context = True)
@register.simple_tag(takes_context=True)
def theme_cookie(context):
request = context['request']
value = request.COOKIES.get('theme', 'light')
Expand Down Expand Up @@ -62,4 +65,4 @@ def as_tick_or_cross(b):
if b:
return mark_safe("""<strong><i class="fas fa-check" style="color: green;"></i><strong>""")
else:
return mark_safe("""<strong><i class="fas fa-times" style="color: red;"></i><strong>""")
return mark_safe("""<strong><i class="fas fa-times" style="color: red;"></i><strong>""")
1 change: 1 addition & 0 deletions ctfpad/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
# challenges
path("challenges/", views.challenges.ChallengeListView.as_view(), name="challenges-list"),
path("challenges/create/", views.challenges.ChallengeCreateView.as_view(), name="challenges-create"),
path("challenges/import/<uuid:ctf>/", views.challenges.ChallengeImportView.as_view(), name="challenges-import"),
path("challenges/create/<uuid:ctf>/", views.challenges.ChallengeCreateView.as_view(), name="challenges-create"),
path("challenges/<uuid:pk>/", views.challenges.ChallengeDetailView.as_view(), name="challenges-detail"),
path("challenges/<uuid:pk>/edit/", views.challenges.ChallengeUpdateView.as_view(), name="challenges-edit"),
Expand Down
63 changes: 59 additions & 4 deletions ctfpad/views/challenges.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@

import json

from django.http.request import HttpRequest
from django.http.response import HttpResponse
from ctfpad.decorators import only_if_authenticated_user
from django.contrib import messages
from django.shortcuts import render, get_object_or_404, redirect
from django.views.generic import ListView, DetailView, UpdateView, DeleteView, CreateView
from django.views.generic import ListView, DetailView, UpdateView, DeleteView, CreateView, FormView
from django.urls import reverse, reverse_lazy
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
Expand All @@ -15,8 +15,9 @@
ChallengeUpdateForm,
ChallengeSetFlagForm,
ChallengeFileCreateForm,
ChallengeImportForm,
)
from ctfpad.models import Challenge, Ctf
from ctfpad.models import Challenge, Ctf, ChallengeCategory
from ctftools.settings import HEDGEDOC_URL

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


class ChallengeImportView(LoginRequiredMixin, FormView):
model = Challenge
template_name = "ctfpad/challenges/import.html"
login_url = "/users/login/"
redirect_field_name = "redirect_to"
form_class = ChallengeImportForm
success_message = "Challenges were successfully imported!"

def get(self, request, *args, **kwargs):
self.initial["ctf"] = self.kwargs.get("ctf")
form = self.form_class(initial=self.initial)
return render(request, self.template_name, {'form': form})

def form_valid(self, form):
ctf_id = self.kwargs.get("ctf")
ctf = Ctf.objects.get(pk=ctf_id)
data = form.cleaned_data['data']

try:
for challenge in data:
category, created = ChallengeCategory.objects.get_or_create(name=challenge["category"].strip().lower())
points = 0
description = ""

if form.cleaned_data['format'] == 'CTFd':
points = challenge.get("value")
elif form.cleaned_data['format'] == 'rCTF':
points = challenge.get("points")
description = challenge.get("description")

defaults = {
"name": challenge.get("name"),
"points": points,
"category": category,
"description": description,
"ctf": ctf,
}

Challenge.objects.update_or_create(
defaults=defaults,
name=challenge.get("name"),
ctf=ctf,
)

messages.success(self.request, "Import successful!")
return super().form_valid(form)
except Exception as e:
messages.error(self.request, f"Error: {str(e)}")
return self.form_invalid(form)

def get_success_url(self):
return reverse("ctfpad:ctfs-detail", kwargs={'pk': self.initial["ctf"]})


class ChallengeDetailView(LoginRequiredMixin, DetailView):
model = Challenge
template_name = "ctfpad/challenges/detail.html"
Expand Down Expand Up @@ -101,7 +156,7 @@ def form_valid(self, form):
if "solvers" in form.cleaned_data:

if (len(form.cleaned_data["solvers"]) > 0 and not form.cleaned_data["flag"]) or \
(len(form.cleaned_data["solvers"]) == 0 and form.cleaned_data["flag"]):
(len(form.cleaned_data["solvers"]) == 0 and form.cleaned_data["flag"]):
messages.error(
self.request, "Cannot set flag without solver(s)")
return redirect("ctfpad:challenges-detail", self.object.id)
Expand Down
28 changes: 28 additions & 0 deletions static/js/challenge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
window.addEventListener('DOMContentLoaded', (event) => {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

awesome, way cleaner

var formatSelect = document.getElementById('id_format');
var dataTextArea = document.getElementById('id_data');

formatSelect.addEventListener('change', function () {
var selectedFormat = this.value;
var placeholderText;

switch (selectedFormat) {
case 'RAW':
placeholderText = 'name | category';
break;
case 'CTFd':
placeholderText = 'paste CTFd JSON /api/v1/challenges';
break;
case 'rCTF':
placeholderText = 'paste rCTF JSON /api/v1/challs';
break;
default:
placeholderText = '';
}

dataTextArea.setAttribute('placeholder', placeholderText);
});

// Trigger the change event on page load to set initial placeholder
formatSelect.dispatchEvent(new Event('change'));
});