最近尝试对CTFd进行二次开发,在这里记录一下开发的过程
PS:本文基于CTFd 3.4.3 开发,不同版本可能存在版本兼容问题。

动态计分板插件

项目链接:https://github.com/Ephemeral1y/ctfd-matrix-scoreboard

用途是在CTF个人赛上,找寻现有的一些插件,在这个基础上改的。

Usage

CTFdCTFd/plugins目录下

git clone https://github.com/Ephemeral1y/ctfd-matrix-scoreboard
docker restart ctfd_ctfd_1

一些参数的修改

__init__.py

NumberOfChallenges = 40 # 题目最大数量

if(solve.date == top[0].date):
    solvenum = solve.challenge_id
    score = score + int(cvalue * 1.1)   #一血加成10%
    blood[solve.challenge_id].append(1)
elif(solve.date == top[1].date):
    solvenum = solve.challenge_id
    score = score + int(cvalue * 1.05)  #二血加成5%
    blood[solve.challenge_id].append(2)
elif(solve.date == top[2].date):
    solvenum = solve.challenge_id
    score = score + int(cvalue * 1.03)  #三血加成3%
    blood[solve.challenge_id].append(3)
else:
    solvenum = solve.challenge_id
    score = score + int(cvalue * 1)
    blood[solve.challenge_id].append(0)

目前只支持reverse,pwn,web,misc,crypto五个方向的前端显示,有需要的可以自行修改源码

增加用户属性

用途是在注册的时候给用户增加学号、姓名的属性,只有自己和后台能看到,方便用户的管理

数据库配置

这个地方网上涉及的资料不多,着实费了一番功夫

CTFd使用的是flask框架,框架使用SQLAlchemy来访问数据库。

数据库建表的模型是在CTFd/models/__init__.py

比如我需要给表users增加snamesid两个属性,分别代表真实姓名和学号

class Users(db.Model):
    __tablename__ = "users"
    __table_args__ = (db.UniqueConstraint("id", "oauth_id"), {})
    # Core attributes
    id = db.Column(db.Integer, primary_key=True)
    oauth_id = db.Column(db.Integer, unique=True)
    # User names are not constrained to be unique to allow for official/unofficial teams.
    name = db.Column(db.String(128))
    password = db.Column(db.String(128))
    email = db.Column(db.String(128), unique=True)
    sname = db.Column(db.String(20))      # 增加姓名
    sid = db.Column(db.String(20))        # 增加学号
    type = db.Column(db.String(80))
    secret = db.Column(db.String(128))

然后在CTFd的根目录下,找到manager.py,这是数据库的管理文件

初始化,这个操作会生成一个migrations文件夹,如果已有则无需执行

python manage.py db init

生成建表文件,这个操作会检查CTFd/models/__init__.py的修改,生成新的建表文件,-m表示注释,会生成文件名含这个的文件。
SQLAlchemy对数据库的操作实际上是先删除整个数据库,然后执行建表文件重新生成数据库
PS:后续开发过程中发现CTFd-whale这个插件安装之后对这个操作会有影响,我的解决方案是暂时下了这个插件,配置好建表语句之后再重新上这个插件。

python manage.py db migrate -m "add_sname_sid"

执行建表文件,在执行这个文件之后数据库即会自动更新。

python manage.py db upgrade

除此之外,以下操作可以查看数据库更新历史

python manage.py db history

以上操作为开发环境下操作,如果是用docker-compose生成的CTFd,直接在命令行执行会无法检测到数据库变化

所以如果是用docker-compose的情况,需要先在docker-compose.yml中,修改CTFd容器内部文件系统可写

.:/opt/CTFd:ro
修改为.:/opt/CTFd

然后进入CTFddocker,在内部执行上述操作,生成建表文件即可,最后不要忘记重新修改文件系统为只读。

修改注册界面

在用户注册的时候,在注册选项里增加两个表单,用来填写学号和姓名

CTFd/themes/core/templates/register.html

<div class="form-group">
    <b>{{ form.name.label }}</b>
    {{ form.name(class="form-control", value=name) }}
    <small class="form-text text-muted">
        Your username on the site
    </small>
</div>
<div class="form-group">
    <b>{{ form.sname.label }}</b>
    {{ form.sname(class="form-control", value=sname) }}
    <small class="form-text text-muted">
        Never shown to the public
    </small>
</div>
<div class="form-group">
    <b>{{ form.sid.label }}</b>
    {{ form.sid(class="form-control", value=sid) }}
    <small class="form-text text-muted">
        Never shown to the public
    </small>
</div>
<div class="form-group">
    <b>{{ form.email.label }}</b>
    {{ form.email(class="form-control", value=email) }}
    <small class="form-text text-muted">
        Never shown to the public
    </small>
</div>

这里只是修改前端显示,还需要后端接收参数进行注册

CTFd/auth.py

接收前端传输数据

if request.method == "POST":
    name = request.form.get("name", "").strip()
    email_address = request.form.get("email", "").strip().lower()
    password = request.form.get("password", "").strip()
    sname = request.form.get("sname", "").strip()
    sid = request.form.get("sid", "").strip()

将接受的数据插入到列

names = Users.query.add_columns("name","id").filter_by(name=name).first()
emails = (Users.query.add_columns("email", "id").filter_by(email=email_address).first())
sname = Users.query.add_columns("sname","id").filter_by(sname=sname).first()
sid = Users.query.add_columns("sid", "id").filter_by(sid=sid).first()

这里我的理解是将数据插入到一个临时的列中,最终会到底层形成一个sql插入语句进行插入记录

对于学号,我还加入了一个检验机制,因为学校的学号只有9位和11位的,所以我对输入在后端进行了检测

vaild_number = validators.validate_sid(request.form.get("sid", "").strip())
if not vaild_number:
    errors.append("Please enter a vaild student number!")

CTFd/utils/validators

def validate_sid(sid):
    if (len(str(sid)) == 11 or len(str(sid)) == 9) and bool(
            re.match("\d+", sid)):
        return True
    else:
        return False

创建用户

with app.app_context():
    user = Users(name=name,
                 email=email_address,
                 password=password,
                 sname=request.form.get("sname", "").strip(),
                 sid=request.form.get("sid", "").strip())

增加字段

CTFd/forms/auth.py

def RegistrationForm(*args, **kwargs):

    class _RegistrationForm(BaseForm):
        name = StringField("User Name", validators=[InputRequired()])
        email = EmailField("Email", validators=[InputRequired()])
        sname = StringField("Real Name", validators=[InputRequired()])
        sid = StringField("Student number", validators=[InputRequired()])
        password = PasswordField("Password", validators=[InputRequired()])
        submit = SubmitField("Submit")

增加后台修改

用户应当被允许在后台修改自己的信息

增加后台显示表单

CTFd/themes/core/templates/settings.html

<div class="form-group">
    <b>{{ form.name.label }}</b>
    {{ form.name(class="form-control", value=name) }}
</div>
<div class="form-group">
    <b>{{ form.email.label }}</b>
    {{ form.email(class="form-control", value=email) }}
</div>
<div class="form-group">
    <b>{{ form.sname.label }}</b>
    {{ form.sname(class="form-control", value=sname) }}
</div><div class="form-group">
    <b>{{ form.sid.label }}</b>
    {{ form.sid(class="form-control", value=sid) }}
</div>
<hr>

后台提交数据与数据库绑定

CTFd/views.py

def settings():
    infos = get_infos()
    errors = get_errors()

    user = get_current_user()
    name = user.name
    email = user.email
    sname = user.sname
    sid = user.sid
    website = user.website
    affiliation = user.affiliation
    country = user.country

数据渲染到设置界面

return render_template(
    "settings.html",
    name=name,
    email=email,
    sname=sname,
    sid=sid,
    website=website,
    affiliation=affiliation,
    country=country,
    tokens=tokens,
    prevent_name_change=prevent_name_change,
    infos=infos,
    errors=errors,
)

自己的字段

CTFd/forms/self.py

def SettingsForm(*args, **kwargs):

    class _SettingsForm(BaseForm):
        name = StringField("User Name")
        email = StringField("Email")
        sname = StringField("Real Name")
        sid = StringField("Student Number")
        password = PasswordField("Password")
        confirm = PasswordField("Current Password")
        affiliation = StringField("Affiliation")
        website = URLField("Website")
        country = SelectField("Country", choices=SELECT_COUNTRIES_LIST)
        submit = SubmitField("Submit")

用户的字段

CTFd/forms/users.py

class UserBaseForm(BaseForm):
    name = StringField("User Name", validators=[InputRequired()])
    email = EmailField("Email", validators=[InputRequired()])
    sname = StringField("Real Name", validators=[InputRequired()])
    sid = StringField("Student Number", validators=[InputRequired()])
    password = PasswordField("Password")
    website = StringField("Website")
    affiliation = StringField("Affiliation")
    country = SelectField("Country", choices=SELECT_COUNTRIES_LIST)
    type = SelectField("Type", choices=[("user", "User"), ("admin", "Admin")])
    verified = BooleanField("Verified")
    hidden = BooleanField("Hidden")
    banned = BooleanField("Banned")
    submit = SubmitField("Submit")

用户模式里面添加

CTFd/schemas/users.py

class UserSchema(ma.ModelSchema):

    class Meta:
        model = Users
        include_fk = True
        dump_only = ("id", "oauth_id", "created", "team_id")
        load_only = ("password", )

    name = field_for(
        Users,
        "name",
        required=True,
        allow_none=False,
        validate=[
            validate.Length(min=1,
                            max=128,
                            error="User names must not be empty")
        ],
    )
    email = field_for(
        Users,
        "email",
        allow_none=False,
        validate=[
            validate.Email(
                "Emails must be a properly formatted email address"),
            validate.Length(min=1, max=128, error="Emails must not be empty"),
        ],
    )
    sname = field_for(
        Users,
        "sname",
        required=True,
        allow_none=False,
        validate=[
            validate.Length(min=1,
                            max=128,
                            error="Real name must not be empty")
        ],
    )
    sid = field_for(
        Users,
        "sid",
        required=True,
        allow_none=False,
        validate=[
            validate.Length(min=1,
                            max=128,
                            error="Student number must not be empty")
        ],
    )

对于不同用户组的显示

    views = {
        "user": [
            "website",
            "name",
            "country",
            "affiliation",
            "bracket",
            "id",
            "oauth_id",
            "fields",
            "team_id",
        ],
        "self": [
            "website",
            "name",
            "email",
            "sname",
            "sid",
            "country",
            "affiliation",
            "bracket",
            "id",
            "oauth_id",
            "password",
            "fields",
            "team_id",
        ],
        "admin": [
            "website",
            "name",
            "sname",
            "sid",
            "created",
            "country",
            "banned",
            "email",
            "affiliation",
            "secret",
            "bracket",
            "hidden",
            "id",
            "oauth_id",
            "password",
            "type",
            "verified",
            "fields",
            "team_id",
        ],
    }

即自己跟后台可见,其他用户不可见

后台修改用户的界面显示

CTFd/themes/admin/templates/modals/users/edit.html

<div class="form-group">
        {{ form.name.label }}
        {{ form.name(class="form-control") }}
    </div>
    <div class="form-group">
        {{ form.email.label }}
        {{ form.email(class="form-control") }}
    </div>
    <div class="form-group">
        {{ form.sname.label }}
        {{ form.sname(class="form-control") }}
    </div>
    <div class="form-group">
        {{ form.sid.label }}
        {{ form.sid(class="form-control") }}
    </div>
    <div class="form-group">
        {{ form.password.label }}
        {{ form.password(class="form-control") }}
    </div>

后台用户显示

CTFd/themes/admin/templates/users/users.html

<th class="sort-col text-center"><b>ID</b></th>
<th class="sort-col text-center"><b>User</b></th>
<th class="d-md-table-cell d-lg-table-cell sort-col text-center"><b>Email</b></th>
<th class="sort-col text-center"><b>Name</b></th>
<th class="sort-col text-center"><b>Number</b></th>
<th class="sort-col text-center"><b>Country</b></th>
<th class="sort-col text-center px-0"><b>Admin</b></th>
<th class="sort-col text-center px-0"><b>Verified</b></th>
<th class="sort-col text-center px-0"><b>Hidden</b></th>
<th class="sort-col text-center px-0"><b>Banned</b></th>
<td class="team-email d-none d-md-table-cell d-lg-table-cell" value="{{ user.email }}">
    {% if user.email %}
    <a href="mailto:{{ user.email }}" target="_blank">{{ user.email | truncate(32) }}</a>
    {% endif %}
</td>
<td class="team-id text-center" value="{{ user.sname }}">
    <a href="{{ url_for('admin.users_detail', user_id=user.id) }}">
        {{ user.sname}}
    </a>
</td>
<td class="team-id text-center" value="{{ user.sid }}">
    {{ user.sid }}
</td>
<td class="team-country text-center" value="{{ user.country if user.country is not none }}">
    <span>
        {% if user.country %}
        <i class="flag-{{ user.country.lower() }}"></i>
        <small>{{ lookup_country_code(user.country) }}</small>
        {% endif %}
    </span>
</td>

增加题目子类别

基于ctfd-pages-theme的修改

ctfd-pages-theme:https://github.com/frankli0324/ctfd-pages-theme

这个想法主要是想要在有分页的基础上给每个页面增加题目的子类别

数据库配置

首先是要给每个challenge增加一个子类型subcategory

操作方法跟上文相同

CTFd/models/__init__.py

class Challenges(db.Model):
    __tablename__ = "challenges"
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(80))
    description = db.Column(db.Text)
    connection_info = db.Column(db.Text)
    max_attempts = db.Column(db.Integer, default=0)
    value = db.Column(db.Integer)
    category = db.Column(db.String(80))
    subcategory = db.Column(db.String(80))   # 增加子类型
    type = db.Column(db.String(80))
    state = db.Column(db.String(80), nullable=False, default="visible")
    requirements = db.Column(db.JSON)

增加新增题目修改

CTFd/themes/admin/templates/challenges/create.html

<form method="POST" action="{{ script_root }}/admin/challenges/new" enctype="multipart/form-data">
    {% block name %}
    <div class="form-group">
        <label>
            Name:<br>
            <small class="form-text text-muted">
                The name of your challenge
            </small>
        </label>
        <input type="text" class="form-control" name="name" placeholder="Enter challenge name">
    </div>
    {% endblock %}

    {% block category %}
    <div class="form-group">
        <label>
            Category:<br>
            <small class="form-text text-muted">
                The category of your challenge
            </small>
        </label>
        <input type="text" class="form-control" name="category" placeholder="Enter challenge category">
    </div>
    {% endblock %}

    {% block subcategory %}
    <div class="form-group">
        <label>
            Subcategory:<br>
            <small class="form-text text-muted">
                The subcategory of your challenge
            </small>
        </label>
        <input type="text" class="form-control" name="subcategory" placeholder="Enter challenge subcategory">
    </div>
    {% endblock %}

后台题目显示

CTFd/themes/admin/templates/challenges/challenges.html

<th class="sort-col text-center"><b>ID</b></th>
<th class="sort-col"><b>Name</b></th>
<th class="d-none d-md-table-cell d-lg-table-cell sort-col"><b>Category</b></th>
<th class="d-none d-md-table-cell d-lg-table-cell sort-col"><b>Subcategory</b></th>
<th class="d-none d-md-table-cell d-lg-table-cell sort-col text-center"><b>Value</b></th>
<th class="d-none d-md-table-cell d-lg-table-cell sort-col text-center"><b>Type</b></th>
<th class="d-none d-md-table-cell d-lg-table-cell sort-col text-center"><b>State</b></th>
<td class="text-center">{{ challenge.id }}</td>
<td><a href="{{ url_for('admin.challenges_detail', challenge_id=challenge.id) }}">{{ challenge.name }}</a></td>
<td class="d-none d-md-table-cell d-lg-table-cell">{{ challenge.category }}</td>
<td class="d-none d-md-table-cell d-lg-table-cell">
    {% if challenge.subcategory != "" %}
        {{challenge.subcategory}}
    {% else %}
        None
    {% endif %}
</td>
<td class="d-none d-md-table-cell d-lg-table-cell text-center">{{ challenge.value }}</td>
<td class="d-none d-md-table-cell d-lg-table-cell text-center">{{ challenge.type }}</td>
<td class="d-none d-md-table-cell d-lg-table-cell text-center">

前端页面修改

修改challengeapi

/CTFd/api/v1/challenges.py

response.append({
    "id":
    challenge.id,
    "type":
    challenge_type.name,
    "name":
    challenge.name,
    "value":
    challenge.value,
    "solves":
    solve_counts.get(challenge.id, solve_count_dfl),
    "solved_by_me":
    challenge.id in user_solves,
    "category":
    challenge.category,
    "tags":
    tag_schema.dump(challenge.tags).data,
    "template":
    challenge_type.templates["view"],
    "script":
    challenge_type.scripts["view"],
    "subcategory":
    challenge.subcategory,
})

这里注意一个问题,子目录未定义会报错。

最后是最复杂的js部分,实现如下

/CTFd/themes/pages/assets/js/pages/challenges.js

import "./main";
import "bootstrap/js/dist/tab";
import { ezQuery, ezAlert } from "../ezq";
import { htmlEntities } from "../utils";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import $ from "jquery";
import CTFd from "../CTFd";
import config from "../config";
import hljs from "highlight.js";

dayjs.extend(relativeTime);

const api_func = {
    teams: x => CTFd.api.get_team_solves({ teamId: x }),
    users: x => CTFd.api.get_user_solves({ userId: x })
};
CTFd._internal.challenge = {};
let challenges = [];
let solves = [];
let pages = [];

const loadChal = id => {
    const chal = $.grep(challenges, chal => chal.id == id)[0];

    if (chal.type === "hidden") {
        ezAlert({
            title: "Challenge Hidden!",
            body: "You haven't unlocked this challenge yet!",
            button: "Got it!"
        });
        return;
    }

    displayChal(chal);
};

const loadChalByName = name => {
    let idx = name.lastIndexOf("-");
    let pieces = [name.slice(0, idx), name.slice(idx + 1)];
    let id = pieces[1];

    const chal = $.grep(challenges, chal => chal.id == id)[0];
    displayChal(chal);
};

const displayChal = chal => {
    return Promise.all([
        CTFd.api.get_challenge({ challengeId: chal.id }),
        $.getScript(config.urlRoot + chal.script),
        $.get(config.urlRoot + chal.template)
    ]).then(responses => {
        const challenge = CTFd._internal.challenge;

        $("#challenge-window").empty();

        // Inject challenge data into the plugin
        challenge.data = responses[0].data;

        // Call preRender function in plugin
        challenge.preRender();

        // Build HTML from the Jinja response in API
        $("#challenge-window").append(responses[0].data.view);

        $("#challenge-window #challenge-input").addClass("form-control");
        $("#challenge-window #challenge-submit").addClass(
            "btn btn-md btn-outline-secondary float-right"
        );

        let modal = $("#challenge-window").find(".modal-dialog");
        if (
            window.init.theme_settings &&
            window.init.theme_settings.challenge_window_size
        ) {
            switch (window.init.theme_settings.challenge_window_size) {
                case "sm":
                    modal.addClass("modal-sm");
                    break;
                case "lg":
                    modal.addClass("modal-lg");
                    break;
                case "xl":
                    modal.addClass("modal-xl");
                    break;
                default:
                    break;
            }
        }

        $(".challenge-solves").click(function(_event) {
            getSolves($("#challenge-id").val());
        });
        $(".nav-tabs a").click(function(event) {
            event.preventDefault();
            $(this).tab("show");
        });

        // Handle modal toggling
        $("#challenge-window").on("hide.bs.modal", function(_event) {
            $("#challenge-input").removeClass("wrong");
            $("#challenge-input").removeClass("correct");
            $("#incorrect-key").slideUp();
            $("#correct-key").slideUp();
            $("#already-solved").slideUp();
            $("#too-fast").slideUp();
        });

        $(".load-hint").on("click", function(_event) {
            loadHint($(this).data("hint-id"));
        });

        $("#challenge-submit").click(function(event) {
            event.preventDefault();
            $("#challenge-submit").addClass("disabled-button");
            $("#challenge-submit").prop("disabled", true);
            CTFd._internal.challenge
                .submit()
                .then(renderSubmissionResponse)
                .then(loadChals)
                .then(markSolves);
        });

        $("#challenge-input").keyup(event => {
            if (event.keyCode == 13) {
                $("#challenge-submit").click();
            }
        });

        challenge.postRender();

        $("#challenge-window")
            .find("pre code")
            .each(function(_idx) {
                hljs.highlightBlock(this);
            });

        window.location.replace(
            window.location.href.split("#")[0] + `#${chal.name}-${chal.id}`
        );
        $("#challenge-window").modal();
    });
};

function renderSubmissionResponse(response) {
    const result = response.data;

    const result_message = $("#result-message");
    const result_notification = $("#result-notification");
    const answer_input = $("#challenge-input");
    result_notification.removeClass();
    result_message.text(result.message);

    if (result.status === "authentication_required") {
        window.location =
            CTFd.config.urlRoot +
            "/login?next=" +
            CTFd.config.urlRoot +
            window.location.pathname +
            window.location.hash;
        return;
    } else if (result.status === "incorrect") {
        // Incorrect key
        result_notification.addClass(
            "alert alert-danger alert-dismissable text-center"
        );
        result_notification.slideDown();

        answer_input.removeClass("correct");
        answer_input.addClass("wrong");
        setTimeout(function() {
            answer_input.removeClass("wrong");
        }, 3000);
    } else if (result.status === "correct") {
        // Challenge Solved
        result_notification.addClass(
            "alert alert-success alert-dismissable text-center"
        );
        result_notification.slideDown();

        if (
            $(".challenge-solves")
            .text()
            .trim()
        ) {
            // Only try to increment solves if the text isn't hidden
            $(".challenge-solves").text(
                parseInt(
                    $(".challenge-solves")
                    .text()
                    .split(" ")[0]
                ) +
                1 +
                " Solves"
            );
        }

        answer_input.val("");
        answer_input.removeClass("wrong");
        answer_input.addClass("correct");
    } else if (result.status === "already_solved") {
        // Challenge already solved
        result_notification.addClass(
            "alert alert-info alert-dismissable text-center"
        );
        result_notification.slideDown();

        answer_input.addClass("correct");
    } else if (result.status === "paused") {
        // CTF is paused
        result_notification.addClass(
            "alert alert-warning alert-dismissable text-center"
        );
        result_notification.slideDown();
    } else if (result.status === "ratelimited") {
        // Keys per minute too high
        result_notification.addClass(
            "alert alert-warning alert-dismissable text-center"
        );
        result_notification.slideDown();

        answer_input.addClass("too-fast");
        setTimeout(function() {
            answer_input.removeClass("too-fast");
        }, 3000);
    }
    setTimeout(function() {
        $(".alert").slideUp();
        $("#challenge-submit").removeClass("disabled-button");
        $("#challenge-submit").prop("disabled", false);
    }, 3000);
}

function markSolves() {
    challenges.map(challenge => {
        if (challenge.solved_by_me) {
            const btn = $(`button[value="${challenge.id}"]`);
            btn.addClass("solved-challenge");
            btn.prepend("<i class='fas fa-check corner-button-check'></i>");
        }
    });
}

async function getSolves(id) {
    const data = (await CTFd.api.get_challenge_solves({ challengeId: id })).data;
    $(".challenge-solves").text(parseInt(data.length) + " Solves");
    const box = $("#challenge-solves-names");
    box.empty();
    for (let i = 0; i < data.length; i++) {
        const id = data[i].account_id;
        const name = data[i].name;
        const date = dayjs(data[i].date).fromNow();
        const account_url = data[i].account_url;
        box.append(
            '<tr><td><a href="{0}">{2}</td><td>{3}</td></tr>'.format(
                account_url, id,
                htmlEntities(name), date
            )
        );
    }
}

async function loadPages() {
    challenges = (await CTFd.api.get_challenge_list()).data;
    const pages_board = $('#pages-board');
    for (var i of challenges) {
        var page = i.category.split('.')[0];
        const pageid = page.replace(/ /g, "-").hashCode();
        if ($.inArray(page, pages) == -1) {
            pages.push(page);
            const page_row = $(
                '<a ' +
                'id="{0}-page-row" class="nav-link" '.format(pageid) +
                'data-toggle="pill" role="tab" href="#"' +
                '>' + page.slice(0, 15) + "</a>"
            );
            if (pages.length === 1) page_row.addClass('active');
            page_row.on('shown.bs.tab', loadChals);
            pages_board.append(page_row);
        }
    }
    loadChals();
}

function loadChals() {
    const categories = [];
    const subcategories = [];
    const $challenges_board = $("#challenges-board");
    const current_page_id = $("#pages-board>.active")[0].id;

    $challenges_board.empty();

    function addCategoryRow(category) {
        categories.push(category);
        const categoryid = category.replace(/ /g, "-").hashCode();
        const categoryrow = $(
            "" +
            '<div id="{0}-row" class="pt-5">'.format(categoryid) +
            '<div class="category-header col-md-12 mb-3">' +
            "</div>" +
            '<div class="category-challenges col-md-12">' +
            '<div class="challenges-row col-md-12"></div>' +
            "</div>" +
            "</div>"
        );
        categoryrow
            .find(".category-header")
            .append($("<h3>" + category + "</h3>"));

        $challenges_board.append(categoryrow);
    }

    function addSubCategoryRow(subcategory) {
        subcategories.push(subcategory);
        if (subcategory === null) subcategory = "";
        const subcategoryid = subcategory.replace(/ /g, "-").hashCode();
        const subcategoryrow = $(
            "" +
            '<div id="{0}-row" class="pt-5">'.format(subcategoryid) +
            '<div class="category-header col-md-12 mb-3">' +
            "</div>" +
            '<div class="category-challenges col-md-12">' +
            '<div class="challenges-row col-md-12"></div>' +
            "</div>" +
            "</div>"
        );
        subcategoryrow
            .find(".category-header")
            .append($("<h4>" + subcategory + "</h4>"));

        $challenges_board.append(subcategoryrow);
    }
    // addSubCategoryRow("Linux");
    for (let i = 0; i <= challenges.length - 1; i++) {
        challenges[i].solves = 0;
        var category = challenges[i].category.split('.')[0];
        const page = '{0}-page-row'.format(challenges[i]
            .category.split('.')[0]
            .replace(/ /g, "-").hashCode());
        if (page !== current_page_id) continue;
        if (category === undefined) category = "";
        if ($.inArray(category, categories) == -1)
            addCategoryRow(category);
    }
    for (let i = 0; i <= challenges.length - 1; i++) {
        challenges[i].solves = 0;
        var subcategory = challenges[i].subcategory;
        const page = '{0}-page-row'.format(challenges[i]
            .category.split('.')[0]
            .replace(/ /g, "-").hashCode());
        if (page !== current_page_id) continue;
        if (subcategory === undefined || subcategory === null || subcategory === "") subcategory = "none";
        if ($.inArray(subcategory, subcategories) == -1) {
            if (subcategory != "none") {
                addSubCategoryRow(subcategory);
            }
        }
    }
    for (let i = 0; i <= challenges.length - 1; i++) {
        var subcategory = challenges[i].subcategory;
        if (subcategory === undefined || subcategory === null || subcategory === "") subcategory = "none";
        if (subcategory != "none") continue;
        const chalinfo = challenges[i];
        const chalid = chalinfo.name.replace(/ /g, "-").hashCode();
        var category = chalinfo.category.split('.')[0];
        if (category === undefined) category = "";
        const page = '{0}-page-row'.format(challenges[i]
            .category.split('.')[0]
            .replace(/ /g, "-").hashCode());
        if (page !== current_page_id) continue;
        const catid = category.replace(/ /g, "-").hashCode();
        const chalwrap = $(
            "<div id='{0}' class='col-md-3 d-inline-block'></div>".format(chalid)
        );
        let chalbutton;

        if (solves.indexOf(chalinfo.id) == -1) {
            chalbutton = $(
                "<button class='btn btn-dark challenge-button w-100 text-truncate pt-3 pb-3 mb-2' value='{0}'></button>".format(
                    chalinfo.id
                )
            );
        } else {
            chalbutton = $(
                "<button class='btn btn-dark challenge-button solved-challenge w-100 text-truncate pt-3 pb-3 mb-2' value='{0}'><i class='fas fa-check corner-button-check'></i></button>".format(
                    chalinfo.id
                )
            );
        }

        const chalheader = $("<p>{0}</p>".format(chalinfo.name));
        const chalscore = $("<span>{0}</span>".format(chalinfo.value));
        for (let j = 0; j < chalinfo.tags.length; j++) {
            const tag = "tag-" + chalinfo.tags[j].value.replace(/ /g, "-");
            chalwrap.addClass(tag);
        }
        chalbutton.append(chalheader);
        chalbutton.append(chalscore);
        chalwrap.append(chalbutton);
        $("#" + catid + "-row")
            .find(".category-challenges > .challenges-row")
            .append(chalwrap);
    }
    for (let i = 0; i <= challenges.length - 1; i++) {
        var subcategory = challenges[i].subcategory;
        if (subcategory === undefined || subcategory === null || subcategory === "") subcategory = "none";
        if (subcategory == "none") continue;
        const subcategoryid = subcategory.replace(/ /g, "-").hashCode();
        const chalinfo = challenges[i];
        const chalid = chalinfo.name.replace(/ /g, "-").hashCode();
        var category = chalinfo.category.split('.')[0];
        if (category === undefined) category = "";
        const page = '{0}-page-row'.format(challenges[i]
            .category.split('.')[0]
            .replace(/ /g, "-").hashCode());
        if (page !== current_page_id) continue;
        const catid = category.replace(/ /g, "-").hashCode();
        const chalwrap = $(
            "<div id='{0}' class='col-md-3 d-inline-block'></div>".format(chalid)
        );
        let chalbutton;

        if (solves.indexOf(chalinfo.id) == -1) {
            chalbutton = $(
                "<button class='btn btn-dark challenge-button w-100 text-truncate pt-3 pb-3 mb-2' value='{0}'></button>".format(
                    chalinfo.id
                )
            );
        } else {
            chalbutton = $(
                "<button class='btn btn-dark challenge-button solved-challenge w-100 text-truncate pt-3 pb-3 mb-2' value='{0}'><i class='fas fa-check corner-button-check'></i></button>".format(
                    chalinfo.id
                )
            );
        }

        const chalheader = $("<p>{0}</p>".format(chalinfo.name));
        const chalscore = $("<span>{0}</span>".format(chalinfo.value));
        for (let j = 0; j < chalinfo.tags.length; j++) {
            const tag = "tag-" + chalinfo.tags[j].value.replace(/ /g, "-");
            chalwrap.addClass(tag);
        }
        chalbutton.append(chalheader);
        chalbutton.append(chalscore);
        chalwrap.append(chalbutton);
        $("#" + subcategoryid + "-row")
            .find(".category-challenges > .challenges-row")
            .append(chalwrap);
    }

    $(".challenge-button").click(function(_event) {
        loadChal(this.value);
        getSolves(this.value);
    });
    markSolves();
}

async function update() {
    await loadPages();
    markSolves();
}

$(async() => {
    await update();
    if (window.location.hash.length > 0) {
        loadChalByName(decodeURIComponent(window.location.hash.substring(1)));
    }

    $("#challenge-input").keyup(function(event) {
        if (event.keyCode == 13) {
            $("#challenge-submit").click();
        }
    });

    $(".nav-tabs a").click(function(event) {
        event.preventDefault();
        $(this).tab("show");
    });

    $("#challenge-window").on("hidden.bs.modal", function(_event) {
        $(".nav-tabs a:first").tab("show");
        history.replaceState("", window.document.title, window.location.pathname);
    });

    $(".challenge-solves").click(function(_event) {
        getSolves($("#challenge-id").val());
    });

    $("#challenge-window").on("hide.bs.modal", function(_event) {
        $("#challenge-input").removeClass("wrong");
        $("#challenge-input").removeClass("correct");
        $("#incorrect-key").slideUp();
        $("#correct-key").slideUp();
        $("#already-solved").slideUp();
        $("#too-fast").slideUp();
    });
});
setInterval(update, 300000); // Update every 5 minutes.

const displayHint = data => {
    ezAlert({
        title: "Hint",
        body: data.html,
        button: "Got it!"
    });
};

const displayUnlock = id => {
    ezQuery({
        title: "Unlock Hint?",
        body: "Are you sure you want to open this hint?",
        success: () => {
            const params = {
                target: id,
                type: "hints"
            };
            CTFd.api.post_unlock_list({}, params).then(response => {
                if (response.success) {
                    CTFd.api.get_hint({ hintId: id }).then(response => {
                        displayHint(response.data);
                    });

                    return;
                }

                ezAlert({
                    title: "Error",
                    body: response.errors.score,
                    button: "Got it!"
                });
            });
        }
    });
};

const loadHint = id => {
    CTFd.api.get_hint({ hintId: id }).then(response => {
        if (response.data.content) {
            displayHint(response.data);
            return;
        }

        displayUnlock(id);
    });
};