this repo has no description
at main 485 lines 17 kB view raw
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}