rhombus/internal/
open_graph.rs

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}