use std::time::Duration;
use axum::{
extract::{Path, State},
http::Uri,
response::{Html, IntoResponse, Response},
Extension, Form, Json,
};
use chrono::{DateTime, Utc};
use dashmap::DashMap;
use minijinja::context;
use serde::Deserialize;
use serde_json::json;
use crate::internal::{auth::User, router::RouterState, routes::meta::PageMeta};
pub async fn route_challenges(
state: State<RouterState>,
Extension(user): Extension<User>,
Extension(page): Extension<PageMeta>,
uri: Uri,
) -> impl IntoResponse {
if let Some(start_time) = state.settings.read().await.start_time {
if !user.is_admin && chrono::Utc::now() < start_time {
let html = state
.jinja
.get_template("locked.html")
.unwrap()
.render(context! {
global => state.global_page_meta,
page,
title => format!("Challenges | {}", state.global_page_meta.title),
user,
})
.unwrap();
return Response::builder()
.header("content-type", "text/html")
.status(200)
.body(html)
.unwrap()
.into_response();
}
}
let challenge_data = state.db.get_challenges();
let team = state.db.get_team_from_id(user.team_id);
let (challenge_data, team) = tokio::join!(challenge_data, team);
let challenge_data = challenge_data.unwrap();
let team = team.unwrap();
let ticket_enabled = {
let settings = state.settings.read().await;
settings
.discord
.as_ref()
.and_then(|d| d.support_channel_id)
.is_some()
};
let challenge_json = json!({
"ticket_enabled": ticket_enabled,
"challenges": challenge_data.challenges.iter().map(|challenge| json!({
"id": challenge.id,
"name": challenge.name,
"description": challenge.description,
"health": if let (Some(healthy), Some(last_checked)) = (challenge.healthy, challenge.last_healthcheck) {
Some(json!({
"last_checked": last_checked,
"healthy": healthy,
}))
} else {
None
},
"category_id": challenge.category_id,
"author_id": challenge.author_id,
"division_points": challenge.division_points.iter().map(|division_points| json!({
"division_id": division_points.division_id,
"points": division_points.points,
"solves": division_points.solves,
})).collect::<serde_json::Value>(),
"attachments": challenge.attachments.iter().map(|attachment| json!({
"name": attachment.name,
"url": attachment.url,
})).collect::<serde_json::Value>(),
})).collect::<serde_json::Value>(),
"categories": challenge_data.categories.iter().map(|category| json!({
"id": category.id,
"name": category.name,
"color": category.color,
})).collect::<serde_json::Value>(),
"authors": challenge_data.authors.iter().map(|author|
(author.0.to_string(), json!({
"name": author.1.name,
"avatar_url": author.1.avatar_url,
}))
).collect::<serde_json::Value>(),
"divisions": challenge_data.divisions.iter().map(|division|
(division.0.to_string(), json!({
"name": division.1.name,
}))
).collect::<serde_json::Value>(),
"team": json!({
"users": team.users.iter().map(|user|
(user.0.to_string(), json!({
"name": user.1.name,
"avatar_url": user.1.avatar_url,
}))
).collect::<serde_json::Value>(),
"solves": team.solves.iter().map(|solve|
(solve.0.to_string(), json!({
"solved_at": solve.1.solved_at,
"user_id": solve.1.user_id,
}))
).collect::<serde_json::Value>(),
})
});
if uri.path().ends_with(".json") {
return Json(challenge_json).into_response();
}
let html = state
.jinja
.get_template("challenges.html")
.unwrap()
.render(context! {
global => state.global_page_meta,
page,
title => format!("Challenges | {}", state.global_page_meta.title),
user,
challenge_json,
})
.unwrap();
Html(html).into_response()
}
pub async fn route_challenge_view(
state: State<RouterState>,
Extension(user): Extension<User>,
Extension(page): Extension<PageMeta>,
challenge_id: Path<i64>,
) -> impl IntoResponse {
if let Some(start_time) = state.settings.read().await.start_time {
if !user.is_admin && chrono::Utc::now() < start_time {
return Response::builder()
.header("content-type", "text/html")
.status(403)
.body("CTF not started yet".to_owned())
.unwrap()
.into_response();
}
}
let challenge_data = state.db.get_challenges();
let team = state.db.get_team_from_id(user.team_id);
let user_writeups = state.db.get_writeups_from_user_id(user.id);
let (challenge_data, team, user_writeups) = tokio::join!(challenge_data, team, user_writeups);
let challenge_data = challenge_data.unwrap();
let team = team.unwrap();
let user_writeups = user_writeups.unwrap();
let challenge = challenge_data
.challenges
.iter()
.find(|c| challenge_id.eq(&c.id))
.unwrap();
let category = challenge_data
.categories
.iter()
.find(|c| challenge.category_id.eq(&c.id))
.unwrap();
Html(
state
.jinja
.get_template("challenge.html")
.unwrap()
.render(context! {
global => state.global_page_meta,
page,
user,
challenge,
category,
team,
divisions => challenge_data.divisions,
user_writeups,
})
.unwrap(),
)
.into_response()
}
pub async fn route_ticket_view(
state: State<RouterState>,
Extension(user): Extension<User>,
Extension(page): Extension<PageMeta>,
challenge_id: Path<i64>,
) -> impl IntoResponse {
if let Some(start_time) = state.settings.read().await.start_time {
if !user.is_admin && chrono::Utc::now() < start_time {
return Response::builder()
.header("content-type", "text/html")
.status(403)
.body("CTF not started yet".to_owned())
.unwrap()
.into_response();
}
}
let lasted_ticket_opened_at = state
.db
.get_last_created_ticket_time(user.id)
.await
.unwrap()
.unwrap_or(DateTime::<Utc>::from_timestamp(0, 0).unwrap());
let next_allowed_ticket_at = lasted_ticket_opened_at + Duration::from_secs(60 * 5);
if Utc::now() < next_allowed_ticket_at {
let minutes_until = (next_allowed_ticket_at - Utc::now()).num_minutes() + 1;
return Response::builder()
.body(format!(
r#"<div id="htmx-toaster" data-toast="error" hx-swap-oob="true">You must wait {} minute{} before submitting another ticket.</div>"#,
minutes_until, if minutes_until == 1 { "" } else { "s" }
))
.unwrap()
.into_response();
}
let challenge_data = state.db.get_challenges();
let team = state.db.get_team_from_id(user.team_id);
let (challenge_data, team) = tokio::join!(challenge_data, team);
let challenge_data = challenge_data.unwrap();
let team = team.unwrap();
let challenge = challenge_data
.challenges
.iter()
.find(|c| challenge_id.eq(&c.id))
.unwrap();
let category = challenge_data
.categories
.iter()
.find(|c| challenge.category_id.eq(&c.id))
.unwrap();
let ticket_template = if let Some(ticket_template) = &challenge.ticket_template {
ticket_template.clone()
} else {
state.settings.read().await.default_ticket_template.clone()
};
Html(
state
.jinja
.get_template("ticket.html")
.unwrap()
.render(context! {
global => state.global_page_meta,
page,
user,
challenge,
category,
team,
ticket_template,
})
.unwrap(),
)
.into_response()
}
#[derive(Deserialize)]
pub struct TicketSubmit {
content: String,
}
pub async fn route_ticket_submit(
state: State<RouterState>,
Extension(user): Extension<User>,
Extension(page): Extension<PageMeta>,
challenge_id: Path<i64>,
Form(form): Form<TicketSubmit>,
) -> impl IntoResponse {
if let Some(start_time) = state.settings.read().await.start_time {
if !user.is_admin && chrono::Utc::now() < start_time {
return Response::builder()
.header("content-type", "text/html")
.status(403)
.body("CTF not started yet".to_owned())
.unwrap()
.into_response();
}
}
let ticket_enabled = {
let settings = state.settings.read().await;
settings
.discord
.as_ref()
.and_then(|d| d.support_channel_id)
.is_some()
};
if !ticket_enabled || state.bot.is_none() {
return Response::builder()
.header("Content-Type", "text/html")
.header("HX-Trigger", "closeModal")
.body("".to_owned())
.unwrap()
.into_response();
}
let lasted_ticket_opened_at = state
.db
.get_last_created_ticket_time(user.id)
.await
.unwrap()
.unwrap_or(DateTime::<Utc>::from_timestamp(0, 0).unwrap());
let next_allowed_ticket_at = lasted_ticket_opened_at + Duration::from_secs(60 * 5);
if Utc::now() < next_allowed_ticket_at {
return Json(json!({
"error": "Too many tickets in a short period of time",
}))
.into_response();
}
let content = form.content;
if content.len() > 1000 {
let html = state
.jinja
.get_template("challenge-submit.html")
.unwrap()
.render(context! {
page,
user,
error => state.localizer.localize(&page.lang, "challenges-error-ticket-too-long", None),
})
.unwrap();
return Response::builder()
.header("content-type", "text/html")
.body(html)
.unwrap()
.into_response();
}
let challenge_data = state.db.get_challenges();
let team = state.db.get_team_from_id(user.team_id);
let (challenge_data, team) = tokio::join!(challenge_data, team);
let challenge_data = challenge_data.unwrap();
let team = team.unwrap();
let challenge = challenge_data
.challenges
.iter()
.find(|c| challenge_id.eq(&c.id))
.unwrap();
let author = challenge_data.authors.get(&challenge.author_id).unwrap();
state
.bot
.as_ref()
.unwrap()
.create_support_thread(&user, &team, challenge, author, content.as_str())
.await
.unwrap();
Response::builder()
.header("Content-Type", "text/html")
.header(
"HX-Trigger",
json!({
"closeModal": true,
})
.to_string(),
)
.body(format!(
r#"<div id="htmx-toaster" data-toast="success" hx-swap-oob="true">{}</div>"#,
state
.localizer
.localize(&page.lang, "challenges-ticket-submitted", None)
.unwrap()
))
.unwrap()
.into_response()
}
#[derive(Deserialize)]
pub struct SubmitChallenge {
flag: String,
}
lazy_static::lazy_static! {
pub static ref TEAM_BURSTED_POINTS: DashMap<i64, i64> = DashMap::new();
}
pub async fn route_challenge_submit(
state: State<RouterState>,
Extension(user): Extension<User>,
Extension(page): Extension<PageMeta>,
challenge_id: Path<i64>,
Form(form): Form<SubmitChallenge>,
) -> impl IntoResponse {
if let Some(start_time) = state.settings.read().await.start_time {
if !user.is_admin && chrono::Utc::now() < start_time {
return Response::builder()
.header("content-type", "text/html")
.status(403)
.body("CTF not started yet".to_owned())
.unwrap();
}
}
let challenge_data = state.db.get_challenges().await.unwrap();
let challenge = challenge_data
.challenges
.iter()
.find(|c| challenge_id.eq(&c.id))
.unwrap();
if challenge.flag != form.flag {
let html = state
.jinja
.get_template("challenge-submit.html")
.unwrap()
.render(context! {
page,
error => state.localizer.localize(&page.lang, "challenges-error-incorrect-flag", None),
})
.unwrap();
return Response::builder()
.header("content-type", "text/html")
.body(html)
.unwrap();
}
if let Some(end_time) = state.settings.read().await.end_time {
if chrono::Utc::now() > end_time {
let html = state
.jinja
.get_template("challenge-submit.html")
.unwrap()
.render(context! {
page,
error => "CTF has ended (correct flag!)",
})
.unwrap();
return Response::builder()
.header("content-type", "text/html")
.body(html)
.unwrap();
}
}
let num_solves = TEAM_BURSTED_POINTS
.get(&user.team_id)
.map(|v| *v.value())
.unwrap_or(0);
if num_solves >= 3 {
let html = state
.jinja
.get_template("challenge-submit.html")
.unwrap()
.render(context! {
page,
error => "Too many solves in a short period of time. Please try again in a few minutes",
})
.unwrap();
return Response::builder()
.header("content-type", "text/html")
.body(html)
.unwrap();
}
let first_bloods = state
.db
.solve_challenge(user.id, user.team_id, challenge)
.await;
if let Err(error) = first_bloods {
tracing::error!("{:#?}", error);
let html = state
.jinja
.get_template("challenge-submit.html")
.unwrap()
.render(context! {
page,
error => state.localizer.localize(&page.lang, "unknown-error", None),
})
.unwrap();
return Response::builder()
.header("content-type", "text/html")
.body(html)
.unwrap();
}
TEAM_BURSTED_POINTS
.entry(user.team_id)
.and_modify(|v| *v += 1)
.or_insert(1);
if let Some(ref bot) = state.bot {
let first_blood_enabled = {
let settings = state.settings.read().await;
settings
.discord
.as_ref()
.and_then(|d| d.first_blood_channel_id)
.is_some()
};
if first_blood_enabled {
let first_bloods = first_bloods.unwrap();
if !first_bloods.division_ids.is_empty() {
let team = state.db.get_team_from_id(user.team_id).await.unwrap();
_ = bot
.send_first_blood(
&user,
&team,
challenge,
&challenge_data.divisions,
&challenge_data.categories,
&first_bloods,
)
.await;
tracing::info!(
user_id = user.id,
challenge_id = challenge.id,
divisions = first_bloods
.division_ids
.iter()
.map(|n| n.to_string())
.collect::<Vec<String>>()
.join(","),
"First blooded"
);
}
}
}
Response::builder()
.header("Content-Type", "text/html")
.header(
"HX-Trigger",
json!({
"manualRefresh": true,
"closeModal": true,
})
.to_string(),
)
.body(format!(
r#"<div id="htmx-toaster" data-toast="success" hx-swap-oob="true">{}</div>"#,
state
.localizer
.localize(&page.lang, "challenges-challenge-solved", None)
.unwrap(),
))
.unwrap()
}
#[derive(Deserialize)]
pub struct SubmitWriteup {
url: String,
}
pub async fn route_writeup_submit(
state: State<RouterState>,
Extension(user): Extension<User>,
Extension(page): Extension<PageMeta>,
challenge_id: Path<i64>,
Form(form): Form<SubmitWriteup>,
) -> impl IntoResponse {
if let Some(start_time) = state.settings.read().await.start_time {
if !user.is_admin && chrono::Utc::now() < start_time {
return Response::builder()
.header("content-type", "text/html")
.status(403)
.body("CTF not started yet".to_owned())
.unwrap();
}
}
if form.url.len() > 256 {
let html = state
.jinja
.get_template("challenge-submit.html")
.unwrap()
.render(context! {
page,
user,
error => state.localizer.localize(&page.lang, "error-writeup-url-too-long", None)
})
.unwrap();
return Response::builder()
.header("content-type", "text/html")
.body(html)
.unwrap();
}
let url = reqwest::Url::parse(&form.url);
if url.is_err() {
let html = state
.jinja
.get_template("challenge-submit.html")
.unwrap()
.render(context! {
page,
user,
error => state.localizer.localize(&page.lang, "challenges-error-writeup-invalid-url", None),
})
.unwrap();
return Response::builder()
.header("content-type", "text/html")
.body(html)
.unwrap();
}
let url = url.unwrap();
let client = reqwest::Client::new();
let response = client
.request(reqwest::Method::GET, url)
.timeout(Duration::from_secs(8))
.send()
.await;
if response.is_err() {
let html = state
.jinja
.get_template("challenge-submit.html")
.unwrap()
.render(context! {
page,
user,
error => "Unknown error",
})
.unwrap();
return Response::builder()
.header("content-type", "text/html")
.body(html)
.unwrap();
}
let response = response.unwrap();
if !response.status().is_success() {
let html = state
.jinja
.get_template("challenge-submit.html")
.unwrap()
.render(context! {
page,
user,
error => state.localizer.localize(&page.lang, "challenges-error-writeup-server-error", None),
})
.unwrap();
return Response::builder()
.header("content-type", "text/html")
.body(html)
.unwrap();
}
state
.db
.add_writeup(user.id, user.team_id, challenge_id.0, &form.url)
.await
.unwrap();
Response::builder()
.header("Content-Type", "text/html")
.header("HX-Trigger", "manualRefresh, closeModal")
.body("".to_owned())
.unwrap()
}
pub async fn route_writeup_delete(
state: State<RouterState>,
Extension(user): Extension<User>,
challenge_id: Path<i64>,
) -> impl IntoResponse {
if let Some(start_time) = state.settings.read().await.start_time {
if !user.is_admin && chrono::Utc::now() < start_time {
return Response::builder()
.header("content-type", "text/html")
.status(403)
.body("CTF not started yet".to_owned())
.unwrap();
}
}
state
.db
.delete_writeup(challenge_id.0, user.id, user.team_id)
.await
.unwrap();
Response::builder()
.header("Content-Type", "text/html")
.header("HX-Trigger", "manualRefresh, closeModal")
.body("".to_owned())
.unwrap()
}