forked from
oppi.li/at-advent
this repo has no description
1pub mod challenges;
2pub mod day;
3
4use crate::advent::day::Day;
5use crate::assets::ChallengesMarkdown;
6use crate::models::db_models::ChallengeProgress;
7use async_trait::async_trait;
8use handlebars::{Handlebars, RenderError};
9use markdown::{CompileOptions, Options};
10use rand::distr::{Alphanumeric, SampleString};
11use rand::seq::IndexedRandom;
12use rust_embed::EmbeddedFile;
13use serde_json::json;
14use sqlx::PgPool;
15use std::str::Utf8Error;
16use thiserror::Error;
17
18pub fn get_implemented_days() -> Vec<u8> {
19 let days_from_env = std::env::var("IMPLEMENTED_DAYS").unwrap_or_else(|_| "1".to_string());
20
21 let max_day: u8 = days_from_env.parse().unwrap_or(1);
22
23 (1..=max_day).collect()
24}
25
26#[derive(Debug, Error)]
27pub enum AdventError {
28 #[error("Database error: {0}")]
29 Database(#[from] sqlx::Error),
30 #[error("Io error: {0}")]
31 Io(#[from] std::io::Error),
32 #[error("Invalid day: {0}. Day must be between 1 and 25")]
33 InvalidDay(i32),
34 #[error("This challenge only has a single challenge")]
35 NoPartTwo,
36 #[error("UTF-8 error: {0}")]
37 Utf8Error(#[from] Utf8Error),
38 #[error("Render error: {0}")]
39 RenderError(#[from] RenderError),
40 #[error("This was not designed to happen: {0}")]
41 ShouldNotHappen(String),
42}
43
44pub enum AdventAction {
45 //If Part one is done it shows both, if not it only shows part one
46 // the Option<String> here is the did of the user. If none only show part one no matter what
47 ViewChallenge(Option<String>),
48
49 //The strings here are the users did's
50 StartPartOne(String),
51 StartPartTwo(String),
52
53 //TODO should I have like SubmitPartOne(String) and it's the code?
54 //These actions will be locked behind logged in
55 SubmitPartOne,
56 SubmitPartTwo,
57}
58
59pub enum AdventPart {
60 One,
61 Two,
62}
63
64#[derive(Clone, Copy, Debug, PartialEq)]
65pub enum CompletionStatus {
66 ///None of the day's challenges have been completed
67 None,
68 ///PartOne of the day's challenges has been completed
69 PartOne,
70 ///PartTwo of the day's challenges has been completed
71 //i dont think this was needed
72 // PartTwo,
73 ///Both of the day's challenges have been completed
74 Both,
75}
76
77pub enum ChallengeCheckResponse {
78 Correct,
79 ///Error message on why it was incorrect
80 Incorrect(String),
81}
82
83pub enum AdventActionResult {
84 ShowPartOne,
85 // If partone is completed, this will be shown
86 ShowPartTwo,
87
88 CorrectSubmission(AdventPart),
89 IncorrectSubmission,
90
91 Completed,
92
93 Error(String),
94}
95
96#[async_trait]
97pub trait AdventChallenge {
98 /// The db pool in case the challenge needs extra access
99 fn pool(&self) -> &PgPool;
100
101 /// The day of the challenge 1-25
102 fn day(&self) -> Day;
103
104 fn get_day_markdown_file(&self, part: AdventPart) -> Result<Option<EmbeddedFile>, AdventError> {
105 let day = self.day().to_string().to_ascii_lowercase();
106 let path = match part {
107 AdventPart::One => format!("{day}/part_one.md"),
108 AdventPart::Two => format!("{day}/part_two.md"),
109 };
110 match ChallengesMarkdown::get(path.as_str()) {
111 None => {
112 log::error!("Missing the part one challenge file for day: {}", day);
113 Ok(None)
114 }
115 Some(day_one_file) => Ok(Some(day_one_file)),
116 }
117 }
118
119 /// Does the day have a part two challenge?
120 fn has_part_two(&self) -> bool;
121 //Commenting this out and just going leave it to who makes the impl. This is less code to write,
122 // but really it's a faster response to just have the author put true or false
123 //
124 // {
125 // match self.get_day_markdown_file(AdventPart::Two) {
126 // Ok(_) => true,
127 // Err(_) => false,
128 // }
129 // }
130
131 /// Does part one require manual verification code input from the user?
132 /// Default is false (verification happens automatically via backend)
133 fn requires_manual_verification_part_one(&self) -> bool {
134 false
135 }
136
137 /// Does part two require manual verification code input from the user?
138 /// Default is false (verification happens automatically via backend)
139 fn requires_manual_verification_part_two(&self) -> bool {
140 false
141 }
142
143 /// Does this challenge's part two have its own submit mechanism?
144 /// Default is false (render the normal submit button)
145 fn custom_submit_part_two(&self) -> bool {
146 false
147 }
148
149 /// The text Markdown for challenge 1
150 async fn markdown_text_part_one(
151 &self,
152 verification_code: Option<String>,
153 additional_context: Option<&serde_json::Value>,
154 ) -> Result<String, AdventError> {
155 let day = self.day();
156
157 //May acutally leave this unwrap or put a panic in case it doesn't exist since it is needed to have a part one
158 match self.get_day_markdown_file(AdventPart::One)? {
159 None => {
160 log::error!("Missing the part one challenge file for day: {}", day);
161 Ok("Someone let the admins know this page is missing. No, this is not an Easter egg, it's an actual bug".to_string())
162 }
163 Some(day_one_file) => {
164 //TODO probably should be a shared variable, but prototyping
165 let reg = Handlebars::new();
166
167 let day_one_text = std::str::from_utf8(day_one_file.data.as_ref())?;
168 let code = verification_code.unwrap_or_else(|| "Login to get a code".to_string());
169 let mut context = json!({"code": code});
170 if let Some(serde_json::Value::Object(map)) = additional_context {
171 if let serde_json::Value::Object(ref mut ctx_map) = context {
172 ctx_map.extend(map.iter().map(|(k, v)| (k.clone(), v.clone())));
173 }
174 }
175 let handlebar_rendered = reg.render_template(day_one_text, &context)?;
176
177 Ok(
178 markdown::to_html_with_options(&handlebar_rendered, &get_markdown_options())
179 .unwrap(),
180 )
181 }
182 }
183 }
184
185 /// The text Markdown for challenge 2, could be None
186 async fn markdown_text_part_two(
187 &self,
188 verification_code: Option<String>,
189 additional_context: Option<&serde_json::Value>,
190 ) -> Result<Option<String>, AdventError> {
191 match self.get_day_markdown_file(AdventPart::Two)? {
192 None => Ok(None),
193 Some(day_two_file) => {
194 //TODO probably should be a shared variable, but prototyping
195 let reg = Handlebars::new();
196
197 let day_two_text = std::str::from_utf8(day_two_file.data.as_ref())?;
198 let code = verification_code.unwrap_or_else(|| "Login to get a code".to_string());
199 let mut context = json!({"code": code});
200 if let Some(serde_json::Value::Object(map)) = additional_context {
201 if let serde_json::Value::Object(ref mut ctx_map) = context {
202 ctx_map.extend(map.iter().map(|(k, v)| (k.clone(), v.clone())));
203 }
204 }
205 let handlebar_rendered = reg.render_template(day_two_text, &context)?;
206
207 Ok(Some(
208 markdown::to_html_with_options(&handlebar_rendered, &get_markdown_options())
209 .unwrap(),
210 ))
211 }
212 }
213 }
214
215 /// Checks to see if the day's challenge had been started for the user
216 async fn day_started(&self, did: &str) -> Result<bool, AdventError> {
217 let exists = sqlx::query_scalar::<_, i64>(
218 "SELECT id FROM challenges WHERE user_did = $1 AND day = $2 LIMIT 1",
219 )
220 .bind(did)
221 .bind(self.day() as i16)
222 .fetch_optional(self.pool())
223 .await?;
224 Ok(exists.is_some())
225 }
226
227 async fn get_days_challenge(
228 &self,
229 did: &str,
230 ) -> Result<Option<ChallengeProgress>, AdventError> {
231 Ok(sqlx::query_as::<_, ChallengeProgress>(
232 "SELECT * FROM challenges WHERE user_did = $1 AND day = $2",
233 )
234 .bind(did)
235 .bind(self.day() as i16)
236 .fetch_optional(self.pool())
237 .await?)
238 }
239
240 /// Hook for challenges to build additional context before the DB write.
241 /// Override this to store extra data (e.g. an at_uri) in the additional_context column.
242 async fn build_additional_context(
243 &self,
244 _did: &str,
245 _part: &AdventPart,
246 _code: &str,
247 ) -> Result<Option<serde_json::Value>, AdventError> {
248 Ok(None)
249 }
250
251 async fn start_challenge(&self, did: String, part: AdventPart) -> Result<String, AdventError> {
252 let code = get_random_token();
253 let additional_context = self.build_additional_context(&did, &part, &code).await?;
254
255 match part {
256 AdventPart::One => {
257 sqlx::query(
258 "INSERT INTO challenges (user_did, day, time_started, verification_code_one, additional_context)
259 VALUES ($1, $2, NOW(), $3, $4)
260 ON CONFLICT (user_did, day)
261 DO UPDATE SET verification_code_one = $3, additional_context = COALESCE($4, challenges.additional_context)
262 WHERE challenges.user_did = $1 AND challenges.day = $2",
263 )
264 .bind(&did)
265 .bind(self.day() as i16)
266 .bind(&code)
267 .bind(&additional_context)
268 .execute(self.pool())
269 .await?;
270 }
271 //TODO just going leave these as an update. It should never ideally be an insert
272 AdventPart::Two => {
273 sqlx::query(
274 "UPDATE challenges
275 SET verification_code_two = $3
276 WHERE challenges.user_did = $1 AND challenges.day = $2",
277 )
278 .bind(&did)
279 .bind(self.day() as i16)
280 .bind(&code)
281 .execute(self.pool())
282 .await?;
283 }
284 }
285
286 Ok(code)
287 }
288
289 /// Marks the challenge as completed.
290 async fn complete_part_one(&self, did: String) -> Result<(), AdventError> {
291 sqlx::query(
292 "UPDATE challenges
293 SET time_challenge_one_completed = COALESCE(time_challenge_one_completed, NOW())
294 WHERE user_did = $1 AND day = $2",
295 )
296 .bind(did)
297 .bind(self.day() as i16)
298 .execute(self.pool())
299 .await?;
300 Ok(())
301 }
302
303 /// Marks the challenge as completed.
304 async fn complete_part_two(&self, did: String) -> Result<(), AdventError> {
305 sqlx::query(
306 "UPDATE challenges
307 SET time_challenge_two_completed = COALESCE(time_challenge_two_completed, NOW())
308 WHERE user_did = $1 AND day = $2",
309 )
310 .bind(did)
311 .bind(self.day() as i16)
312 .execute(self.pool())
313 .await?;
314 Ok(())
315 }
316
317 async fn get_completed_status(
318 &self,
319 did: Option<String>,
320 ) -> Result<CompletionStatus, AdventError> {
321 match did {
322 None => Ok(CompletionStatus::None),
323 Some(did) => {
324 let day = self.day() as i32;
325 let result = sqlx::query!(
326 "SELECT time_challenge_one_completed, time_challenge_two_completed
327 FROM challenges
328 WHERE user_did = $1 AND day = $2",
329 did,
330 day
331 )
332 .fetch_optional(self.pool())
333 .await?;
334
335 Ok(match result {
336 None => CompletionStatus::None,
337 Some(row) => match (
338 row.time_challenge_one_completed,
339 row.time_challenge_two_completed,
340 ) {
341 (None, None) => CompletionStatus::None,
342 (Some(_), None) => CompletionStatus::PartOne,
343 (Some(_), Some(_)) => CompletionStatus::Both,
344 _ => panic!(
345 "This should never happen as in part one shouldn't be not done but 2 is"
346 ),
347 },
348 })
349 }
350 }
351 }
352
353 ///This is where the magic happens, aka logic to check if the user got it. Verification code is optional cause sometiems you may need to find it somewhere, sometimes maybe the backend does
354 async fn check_part_one(
355 &self,
356 did: String,
357 verification_code: Option<String>,
358 ) -> Result<ChallengeCheckResponse, AdventError>;
359
360 ///This is where the magic happens, aka logic to check if the user got it. Verification code is optional cause sometiems you may need to find it somewhere, sometimes maybe the backend does
361 /// part two does have a hard error if its called and there is not a part 2
362 async fn check_part_two(
363 &self,
364 _did: String,
365 _verification_code: Option<String>,
366 ) -> Result<ChallengeCheckResponse, AdventError> {
367 unimplemented!("Second day challenges are optional")
368 }
369}
370
371pub fn get_random_token() -> String {
372 let mut rng = rand::rng();
373
374 let full_code = Alphanumeric.sample_string(&mut rng, 10);
375
376 let slice_one = &full_code[0..5].to_ascii_uppercase();
377 let slice_two = &full_code[5..10].to_ascii_uppercase();
378 format!("{slice_one}-{slice_two}")
379}
380
381fn get_markdown_options() -> Options {
382 Options {
383 parse: Default::default(),
384 compile: CompileOptions {
385 //Setting this to allow HTML in the markdown. So please be careful what you put in there
386 allow_dangerous_html: true,
387 ..Default::default()
388 },
389 }
390}
391
392/// Get the globally unlocked day from the settings table.
393/// Returns the day number that all users are unlocked up to.
394pub async fn get_global_unlock_day(pool: &PgPool) -> Result<u8, AdventError> {
395 let result = sqlx::query_scalar::<_, i32>("SELECT unlocked_up_to_day FROM settings LIMIT 1")
396 .fetch_optional(pool)
397 .await?;
398
399 Ok(result.unwrap_or(1) as u8)
400}
401
402/// Get completion status for all 25 days at once
403/// Returns a Vec of tuples (day_number, CompletionStatus) for days 1-25
404pub async fn get_all_days_completion_status(
405 pool: &PgPool,
406 did: Option<&String>,
407) -> Result<Vec<(u8, CompletionStatus)>, AdventError> {
408 // If no user is logged in, return None for all days
409 let did = match did {
410 Some(d) => d,
411 None => {
412 return Ok((1..=25).map(|day| (day, CompletionStatus::None)).collect());
413 }
414 };
415
416 // Query all challenge progress for this user in a single query
417 let results = sqlx::query!(
418 "SELECT day, time_challenge_one_completed, time_challenge_two_completed
419 FROM challenges
420 WHERE user_did = $1 AND day BETWEEN 1 AND 25
421 ORDER BY day",
422 did
423 )
424 .fetch_all(pool)
425 .await?;
426
427 // Create a map of day -> completion status
428 let mut status_map: std::collections::HashMap<u8, CompletionStatus> =
429 std::collections::HashMap::new();
430
431 for row in results {
432 let day = row.day as u8;
433 let status = match (
434 row.time_challenge_one_completed,
435 row.time_challenge_two_completed,
436 ) {
437 (None, None) => CompletionStatus::None,
438 (Some(_), None) => CompletionStatus::PartOne,
439 (Some(_), Some(_)) => CompletionStatus::Both,
440 _ => CompletionStatus::None,
441 };
442 status_map.insert(day, status);
443 }
444
445 // Build the result vec for all 25 days
446 let result: Vec<(u8, CompletionStatus)> = (1..=25)
447 .map(|day| {
448 let status = status_map
449 .get(&day)
450 .copied()
451 .unwrap_or(CompletionStatus::None);
452 (day, status)
453 })
454 .collect();
455
456 Ok(result)
457}
458
459const VERY_BAD_JOKE_SETUPS: &[&str] = &[
460 "what do you call a cow with two legs?",
461 "what do bees do if they need a ride?",
462 "what do you call a monkey that loves doritos?",
463 "why did the can crusher quit her job?",
464 "when’s the best time to go to the dentist?",
465 "why do seagulls fly over the sea?",
466 "what do you call a farm that makes bad jokes?",
467 "why do fish live in saltwater?",
468 "what kind of streets do ghosts haunt?",
469 "what do you call it when one cow spies on another?",
470 "what happens when a frog’s car breaks down?",
471 "what does a zombie vegetarian eat?",
472 "what do you call it when a snowman throws a tantrum?",
473 "why did the scarecrow win an award?",
474 "what did the buffalo say when his son left?",
475 "wait, you don’t want to hear a joke about potassium?",
476 "how do you organize a space party?",
477 "what did one casket say to the other casket?",
478];
479
480/// Gets a random joke from the VERY_BAD_JOKE_SETUPS array
481pub fn get_a_joke() -> &'static str {
482 VERY_BAD_JOKE_SETUPS
483 .choose(&mut rand::rng())
484 .unwrap_or(&"no joke")
485}