1use std::collections::BTreeMap;
2use std::time::Duration;
3
4use axum::extract::Path;
5use axum::Json;
6use axum::{body::Body, extract::State, http::Response, response::IntoResponse};
7use chrono::{DateTime, Utc};
8use dashmap::DashMap;
9use lazy_static::lazy_static;
10use minijinja::context;
11use resvg::{tiny_skia, usvg};
12use rust_embed::RustEmbed;
13use serde::{Deserialize, Serialize};
14use serde_json::json;
15use tokio::sync::RwLock;
16use unicode_segmentation::UnicodeSegmentation;
17
18use crate::builder::find_image_file;
19use crate::internal::{
20 database::{cache::TimedCache, provider::StatisticsCategory},
21 router::RouterState,
22};
23
24#[derive(RustEmbed)]
25#[folder = "fonts"]
26struct Fonts;
27
28lazy_static! {
29 static ref GLOBAL_OPTIONS: usvg::Options<'static> = {
30 let resolve_data = Box::new(|_: &str, _: std::sync::Arc<Vec<u8>>, _: &usvg::Options| None);
31
32 let resolve_string = Box::new(move |href: &str, _: &usvg::Options| {
33 let logo = find_image_file("static/logo").and_then(|logo| {
34 let data = std::sync::Arc::new(std::fs::read(&logo).unwrap());
35 logo.extension().and_then(|ext| match ext.to_str() {
36 Some("svg") => Some(usvg::ImageKind::SVG(
37 usvg::Tree::from_data(&data, &usvg::Options::default()).unwrap(),
38 )),
39 Some("png") => Some(usvg::ImageKind::PNG(data)),
40 Some("webp") => Some(usvg::ImageKind::WEBP(data)),
41 Some("jpg") | Some("jpeg") => Some(usvg::ImageKind::JPEG(data)),
42 Some("gif") => Some(usvg::ImageKind::GIF(data)),
43 _ => None,
44 })
45 });
46
47 match href {
48 "logo" => logo,
49 _ => None,
50 }
51 });
52
53 let mut opt = usvg::Options::default();
54 opt.fontdb_mut().load_system_fonts();
55 opt.fontdb_mut()
56 .load_font_data(Fonts::get("inter/Inter.ttc").unwrap().data.to_vec());
57 opt.image_href_resolver = usvg::ImageHrefResolver {
58 resolve_data,
59 resolve_string,
60 };
61 opt
62 };
63}
64
65fn convert_svg_to_png(svg: &str) -> Vec<u8> {
66 let tree = usvg::Tree::from_data(svg.as_bytes(), &GLOBAL_OPTIONS).unwrap();
67 let pixmap_size = tree.size().to_int_size();
68 let mut pixmap = tiny_skia::Pixmap::new(pixmap_size.width(), pixmap_size.height()).unwrap();
69 resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut());
70 pixmap.encode_png().unwrap()
71}
72
73#[derive(Debug, Serialize)]
74pub struct SiteOGImage {
75 pub title: String,
76 pub location_url: String,
77 pub description: Option<String>,
78 pub start_time: Option<DateTime<Utc>>,
79 pub end_time: Option<DateTime<Utc>>,
80 pub organizer: Option<String>,
81}
82
83#[derive(Debug, Serialize)]
84pub struct CTFtimeMetaInfo {
85 pub teams_interested: u64,
86 pub weight: f64,
87}
88
89#[derive(Debug, Serialize)]
90pub struct TeamMeta {
91 pub name: String,
92 pub score: u64,
93}
94
95#[derive(Debug, Serialize)]
96pub struct DivisionMeta {
97 pub name: String,
98 pub places: Vec<TeamMeta>,
99}
100
101lazy_static! {
102 pub static ref DEFAULT_IMAGE_CACHE: RwLock<Option<CachedImage>> = None.into();
103}
104
105pub struct CachedImage {
106 pub at: DateTime<Utc>,
107 pub data: Vec<u8>,
108}
109
110pub async fn route_default_og_image(state: State<RouterState>) -> impl IntoResponse {
111 {
112 let image_cache = DEFAULT_IMAGE_CACHE.read().await;
113 if image_cache
114 .as_ref()
115 .map(|cache| Utc::now() < cache.at + chrono::Duration::minutes(5))
116 .unwrap_or(false)
117 {
118 return Response::builder()
119 .header("Content-Type", "image/png")
120 .body(Body::from(image_cache.as_ref().unwrap().data.clone()))
121 .unwrap();
122 }
123 }
124
125 let site = {
126 let settings = state.settings.read().await;
127 SiteOGImage {
128 title: settings.title.clone(),
129 description: settings.description.clone(),
130 start_time: settings.start_time,
131 end_time: settings.end_time,
132 location_url: settings.location_url.clone(),
133 organizer: settings.organizer.clone(),
134 }
135 };
136
137 let stats = state.db.get_site_statistics().await.unwrap();
138
139 let ctftime = match &state.settings.read().await.ctftime {
140 Some(ctftime) => 'arm: {
141 #[derive(Deserialize)]
142 struct CTFTimeQuery {
143 participants: i64,
144 weight: f64,
145 }
146
147 let Ok(response) = reqwest::get(format!(
148 "https://ctftime.org/api/v1/events/{}/",
149 ctftime.client_id
150 ))
151 .await
152 else {
153 break 'arm None;
154 };
155
156 let Ok(info) = response.json::<CTFTimeQuery>().await else {
157 break 'arm None;
158 };
159
160 Some(CTFtimeMetaInfo {
161 weight: info.weight,
162 teams_interested: info.participants as u64,
163 })
164 }
165 None => None,
166 };
167
168 let url = reqwest::Url::parse(&site.location_url).unwrap();
169 let location = url
170 .as_str()
171 .trim_start_matches(url.scheme())
172 .trim_start_matches("://")
173 .trim_end_matches("/");
174
175 let ctf_started = site
176 .start_time
177 .map(|start_time| chrono::Utc::now() > start_time)
178 .unwrap_or(true);
179
180 let ctf_ended = site
181 .end_time
182 .map(|end_time| chrono::Utc::now() > end_time)
183 .unwrap_or(false);
184
185 let ctf_start_time = site
186 .start_time
187 .map(|start_time| start_time.format("%A, %B %-d, %Y at %H:%MZ").to_string());
188
189 let ctf_end_time = site
190 .end_time
191 .map(|end_time| end_time.format("%A, %B %-d, %Y at %H:%MZ").to_string());
192
193 let mut division_meta = Vec::with_capacity(state.divisions.len());
194 for division in state.divisions.iter() {
195 let mut places = Vec::with_capacity(3);
196 let leaderboard = state
197 .db
198 .get_leaderboard(division.id, Some(0))
199 .await
200 .unwrap();
201 leaderboard.entries.iter().take(3).for_each(|entry| {
202 places.push(TeamMeta {
203 name: entry.team_name.clone(),
204 score: entry.score as u64,
205 });
206 });
207
208 division_meta.push(DivisionMeta {
209 name: division.name.clone(),
210 places,
211 });
212 }
213
214 let description = site.description.as_ref().map(|desc| wrap_text(desc, 74));
215
216 let svg = state
217 .jinja
218 .get_template("og.svg")
219 .unwrap()
220 .render(context! {
221 site,
222 stats,
223 ctftime,
224 ctf_started,
225 ctf_ended,
226 location,
227 ctf_start_time,
228 ctf_end_time,
229 division_meta,
230 description,
231 })
232 .unwrap();
233
234 let png = convert_svg_to_png(&svg);
235
236 let new_image = CachedImage {
237 at: Utc::now(),
238 data: png.clone(),
239 };
240 {
241 DEFAULT_IMAGE_CACHE.write().await.replace(new_image);
242 }
243
244 Response::builder()
245 .header("Content-Type", "image/png")
246 .body(Body::from(png))
247 .unwrap()
248}
249
250lazy_static::lazy_static! {
251 pub static ref TEAM_OG_IMAGE_CACHE: DashMap<i64, TimedCache<Vec<u8>>> = DashMap::new();
252}
253
254pub async fn route_team_og_image(
255 state: State<RouterState>,
256 team_id: Path<i64>,
257) -> impl IntoResponse {
258 let Ok(team) = state.db.get_team_from_id(team_id.0).await else {
259 return Json(json!({
260 "error": "Team not found",
261 }))
262 .into_response();
263 };
264
265 if let Some(png) = TEAM_OG_IMAGE_CACHE.get(&team_id) {
266 return Response::builder()
267 .header("Content-Type", "image/png")
268 .body(Body::from(png.value.clone()))
269 .unwrap();
270 }
271
272 let challenge_data = state.db.get_challenges();
273 let standings = state.db.get_team_standings(team_id.0);
274 let (challenge_data, standings) = tokio::join!(challenge_data, standings);
275 let challenge_data = challenge_data.unwrap();
276 let standings = standings.unwrap();
277
278 let site = {
279 let settings = state.settings.read().await;
280 SiteOGImage {
281 title: settings.title.clone(),
282 description: settings.description.clone(),
283 start_time: settings.start_time,
284 end_time: settings.end_time,
285 location_url: settings.location_url.clone(),
286 organizer: settings.organizer.clone(),
287 }
288 };
289
290 let url = reqwest::Url::parse(&site.location_url).unwrap();
291 let location = url
292 .as_str()
293 .trim_start_matches(url.scheme())
294 .trim_start_matches("://")
295 .trim_end_matches("/");
296
297 let ctf_started = site
298 .start_time
299 .map(|start_time| chrono::Utc::now() > start_time)
300 .unwrap_or(true);
301
302 let ctf_ended = site
303 .end_time
304 .map(|end_time| chrono::Utc::now() > end_time)
305 .unwrap_or(false);
306
307 let ctf_start_time = site
308 .start_time
309 .map(|start_time| start_time.format("%A, %B %-d, %Y at %H:%MZ").to_string());
310
311 let ctf_end_time = site
312 .end_time
313 .map(|end_time| end_time.format("%A, %B %-d, %Y at %H:%MZ").to_string());
314
315 let num_writeups: usize = team.writeups.values().map(|w| w.len()).sum();
316
317 let num_solves = team.solves.len();
318 let num_users = team.users.len();
319
320 let mut categories = BTreeMap::<i64, StatisticsCategory>::new();
321 for challenge_id in team.solves.keys() {
322 let challenge = challenge_data
323 .challenges
324 .iter()
325 .find(|c| c.id == *challenge_id)
326 .unwrap();
327 let category = challenge_data
328 .categories
329 .iter()
330 .find(|c| c.id == challenge.category_id)
331 .unwrap();
332 categories
333 .entry(category.id)
334 .and_modify(|c| c.num += 1)
335 .or_insert_with(|| StatisticsCategory {
336 color: category.color.clone(),
337 name: category.name.clone(),
338 num: 1,
339 });
340 }
341 let mut categories = categories.values().collect::<Vec<_>>();
342 categories.sort();
343
344 let description = site.description.as_ref().map(|desc| wrap_text(desc, 74));
345
346 let svg = state
347 .jinja
348 .get_template("og-team.svg")
349 .unwrap()
350 .render(context! {
351 site,
352 ctf_started,
353 ctf_ended,
354 location,
355 ctf_start_time,
356 ctf_end_time,
357 divisions => state.divisions,
358 standings => standings.standings,
359 num_solves,
360 num_users,
361 categories,
362 team,
363 num_writeups,
364 description,
365 })
366 .unwrap();
367
368 let png = convert_svg_to_png(&svg);
369
370 let cache = TimedCache::new(png.clone());
371 {
372 TEAM_OG_IMAGE_CACHE.insert(team_id.0, cache);
373 }
374
375 Response::builder()
376 .header("Content-Type", "image/png")
377 .body(Body::from(png))
378 .unwrap()
379}
380
381lazy_static::lazy_static! {
382 pub static ref USER_OG_IMAGE_CACHE: DashMap<i64, TimedCache<Vec<u8>>> = DashMap::new();
383}
384
385pub async fn route_user_og_image(
386 state: State<RouterState>,
387 user_id: Path<i64>,
388) -> impl IntoResponse {
389 let Ok(user) = state.db.get_user_from_id(user_id.0).await else {
390 return Json(json!({
391 "error": "User not found",
392 }))
393 .into_response();
394 };
395
396 if let Some(png) = USER_OG_IMAGE_CACHE.get(&user_id) {
397 return Response::builder()
398 .header("Content-Type", "image/png")
399 .body(Body::from(png.value.clone()))
400 .unwrap();
401 }
402
403 let challenge_data = state.db.get_challenges();
404 let team = state.db.get_team_from_id(user.team_id);
405 let (challenge_data, team) = tokio::join!(challenge_data, team);
406 let challenge_data = challenge_data.unwrap();
407 let team = team.unwrap();
408
409 let site = {
410 let settings = state.settings.read().await;
411 SiteOGImage {
412 title: settings.title.clone(),
413 description: settings.description.clone(),
414 start_time: settings.start_time,
415 end_time: settings.end_time,
416 location_url: settings.location_url.clone(),
417 organizer: settings.organizer.clone(),
418 }
419 };
420
421 let url = reqwest::Url::parse(&site.location_url).unwrap();
422 let location = url
423 .as_str()
424 .trim_start_matches(url.scheme())
425 .trim_start_matches("://")
426 .trim_end_matches("/");
427
428 let ctf_started = site
429 .start_time
430 .map(|start_time| chrono::Utc::now() > start_time)
431 .unwrap_or(true);
432
433 let ctf_ended = site
434 .end_time
435 .map(|end_time| chrono::Utc::now() > end_time)
436 .unwrap_or(false);
437
438 let ctf_start_time = site
439 .start_time
440 .map(|start_time| start_time.format("%A, %B %-d, %Y at %H:%MZ").to_string());
441
442 let ctf_end_time = site
443 .end_time
444 .map(|end_time| end_time.format("%A, %B %-d, %Y at %H:%MZ").to_string());
445
446 let mut categories = BTreeMap::<i64, StatisticsCategory>::new();
447 for (challenge_id, solve) in team.solves.iter() {
448 if solve.user_id != user.id {
449 continue;
450 }
451
452 let challenge = challenge_data
453 .challenges
454 .iter()
455 .find(|c| c.id == *challenge_id)
456 .unwrap();
457 let category = challenge_data
458 .categories
459 .iter()
460 .find(|c| c.id == challenge.category_id)
461 .unwrap();
462 categories
463 .entry(category.id)
464 .and_modify(|c| c.num += 1)
465 .or_insert_with(|| StatisticsCategory {
466 color: category.color.clone(),
467 name: category.name.clone(),
468 num: 1,
469 });
470 }
471 let mut categories = categories.values().collect::<Vec<_>>();
472 categories.sort();
473
474 let num_solves = categories.len();
475
476 let description = site.description.as_ref().map(|desc| wrap_text(desc, 74));
477
478 let svg = state
479 .jinja
480 .get_template("og-user.svg")
481 .unwrap()
482 .render(context! {
483 site,
484 ctf_started,
485 ctf_ended,
486 location,
487 ctf_start_time,
488 ctf_end_time,
489 divisions => state.divisions,
490 num_solves,
491 categories,
492 user,
493 team,
494 description,
495 })
496 .unwrap();
497
498 let png = convert_svg_to_png(&svg);
499
500 let cache = TimedCache::new(png.clone());
501 {
502 USER_OG_IMAGE_CACHE.insert(user_id.0, cache);
503 }
504
505 Response::builder()
506 .header("Content-Type", "image/png")
507 .body(Body::from(png))
508 .unwrap()
509}
510
511pub fn open_graph_cache_evictor(seconds: u64) {
512 tokio::task::spawn(async move {
513 let duration = Duration::from_secs(seconds);
514 loop {
515 tokio::time::sleep(duration).await;
516 let evict_threshold = (chrono::Utc::now() - duration).timestamp();
517
518 let mut count: i64 = 0;
519 TEAM_OG_IMAGE_CACHE.retain(|_, v| {
520 if v.insert_timestamp > evict_threshold {
521 true
522 } else {
523 count += 1;
524 false
525 }
526 });
527 if count > 0 {
528 tracing::trace!(count, "Evicted team og image cache");
529 }
530
531 let mut count: i64 = 0;
532 USER_OG_IMAGE_CACHE.retain(|_, v| {
533 if v.insert_timestamp > evict_threshold {
534 true
535 } else {
536 count += 1;
537 false
538 }
539 });
540 if count > 0 {
541 tracing::trace!(count, "Evicted user og image cache");
542 }
543 }
544 });
545}
546
547fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
548 let mut lines = Vec::new();
549 let mut current_line = String::new();
550 let mut line_len = 0;
551
552 for word in text.split_whitespace() {
553 let word_len = word.graphemes(true).count();
554
555 if line_len + word_len > max_width {
556 lines.push(current_line);
557 current_line = String::new();
558 line_len = 0;
559 }
560
561 if line_len > 0 {
562 current_line.push(' ');
563 line_len += 1;
564 }
565
566 current_line.push_str(word);
567 line_len += word_len;
568 }
569
570 if !current_line.is_empty() {
571 lines.push(current_line);
572 }
573
574 lines
575}