diff --git a/Cargo.lock b/Cargo.lock index 12a8393..ce362cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -319,6 +319,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "rustversion", @@ -913,6 +914,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "enum-as-inner" version = "0.6.1" @@ -1955,6 +1965,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "multibase" version = "0.9.1" diff --git a/Cargo.toml b/Cargo.toml index ddf36f5..3c3a36a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ resolver = "2" [workspace.dependencies] -axum = "0.8.4" +axum = { version = "0.8.4", features = ["multipart"] } atrium-common = "0.1.3" atrium-crypto = "0.1.3" atrium-api = "0.25.7" diff --git a/shared/challenges_markdown/three/part_two.md b/shared/challenges_markdown/three/part_two.md index f19b62f..1d2ffaa 100644 --- a/shared/challenges_markdown/three/part_two.md +++ b/shared/challenges_markdown/three/part_two.md @@ -1,2 +1,19 @@ -This will be them creating a car export with one collection in it and it holds the verification code. We tell them the -collection, rkey, and code to place in it \ No newline at end of file + +make a valid CAR file and upload it + +the inspection will check for: + +- **Collection:** `codes.advent.challenge.day` +- **Record key:** `3` +- **Field:** `verificationCode` set to `{{code}}` + +any valid DID will do for the commit + +(TODO: we're not checking the signature, right? are we checking CID?) + +
+
+ + +
+
diff --git a/shared/src/advent/challenges/day_three/day_three.rs b/shared/src/advent/challenges/day_three/day_three.rs index 54f44a2..7e8d168 100644 --- a/shared/src/advent/challenges/day_three/day_three.rs +++ b/shared/src/advent/challenges/day_three/day_three.rs @@ -24,7 +24,7 @@ impl AdventChallenge for DayThree { } fn has_part_two(&self) -> bool { - false + true } fn requires_manual_verification_part_one(&self) -> bool { diff --git a/shared/src/advent/challenges/day_three/repo.rs b/shared/src/advent/challenges/day_three/repo.rs index 3ded0b9..50c2152 100644 --- a/shared/src/advent/challenges/day_three/repo.rs +++ b/shared/src/advent/challenges/day_three/repo.rs @@ -7,18 +7,20 @@ use atrium_repo::{ Repository, blockstore::{CarStore, MemoryBlockStore}, }; -use serde::Serialize; +use serde::{Deserialize, Serialize}; const CHALLENGE_DID: &str = "did:plc:3oktyyf7u4ecnvdwf3ogehpd"; #[derive(Debug, thiserror::Error)] -pub enum CarBuildError { +pub enum CarFail { #[error("Signing error: {0}")] Signing(#[from] atrium_crypto::Error), #[error("Repo error: {0}")] Repo(#[from] atrium_repo::repo::Error), #[error("CAR error: {0}")] Car(#[from] atrium_repo::blockstore::CarError), + #[error("Bad car: {0}")] + BadCar(String), } /// secret record type @@ -30,6 +32,14 @@ pub struct ChallengeRecord<'a> { pub message: &'a str, } +/// uploaded record type +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubmittedRecord { + pub verification_code: String, + pub response: Option, +} + /// make a single-record atproto car /// /// returns CAR bytes @@ -37,7 +47,7 @@ pub async fn manufacture_car( collection: &Nsid, rkey: &RecordKey, verification_code: &str, -) -> Result, CarBuildError> { +) -> Result, CarFail> { let did = CHALLENGE_DID.parse().unwrap(); // set up repo @@ -69,6 +79,30 @@ pub async fn manufacture_car( Ok(car_bytes) } +/// get a verification code (and joke response??? from submitted car) +pub async fn inspect_car( + car_bytes: &[u8], + collection: &Nsid, + rkey: &RecordKey, +) -> Result { + let mut car = CarStore::open(Cursor::new(car_bytes)).await?; + + let root = car + .roots() + .next() + .ok_or(CarFail::BadCar("no roots".into()))?; + + let mut repo = Repository::open(&mut car, root).await?; + + let key = format!("{}/{}", collection.as_str(), rkey.as_str()); + let record = repo + .get_raw(&key) + .await? + .ok_or(CarFail::BadCar("missing record".into()))?; + + Ok(record) +} + #[cfg(test)] mod tests { use super::*; diff --git a/web/src/handlers/custom/day_three.rs b/web/src/handlers/custom/day_three.rs new file mode 100644 index 0000000..3861a1f --- /dev/null +++ b/web/src/handlers/custom/day_three.rs @@ -0,0 +1,120 @@ +use crate::{AppState, error_response, session::AxumSessionStore}; +use axum::{ + extract::{Multipart, State}, + http::StatusCode, + response::{IntoResponse, Redirect, Response}, +}; +use crate::session::{FlashMessage, set_flash_message}; +use shared::advent::challenges::day_three::DayThree; +use shared::advent::{AdventChallenge, CompletionStatus}; +use super::log_and_respond; + +pub async fn inspect_car( + state: State, + mut session: AxumSessionStore, + mut multipart: Multipart, +) -> Result { + let did = session.get_did().ok_or_else(|| { + error_response(StatusCode::FORBIDDEN, "You need to be logged in") + })?; + + // Extract the uploaded file from the multipart form + let mut car_bytes: Option> = None; + while let Some(field) = multipart.next_field().await.map_err( + log_and_respond(StatusCode::BAD_REQUEST, "Invalid multipart data") + )? { + if field.name() == Some("car_file") { + car_bytes = Some( + field + .bytes() + .await + .map_err(log_and_respond(StatusCode::BAD_REQUEST, "Failed to read upload"))? + .to_vec() + ); + break; + } + } + + let Some(car_bytes) = car_bytes else { + return Err(error_response(StatusCode::BAD_REQUEST, "No car_file field in upload")) + }; + + // Check that part one is done and part two is in progress + let challenge = DayThree { + pool: state.postgres_pool.clone(), + oauth_client: None, + }; + + let status = challenge + .get_completed_status(Some(did.clone())) + .await + .map_err(log_and_respond(StatusCode::INTERNAL_SERVER_ERROR, "Error checking status"))?; + + if status != CompletionStatus::PartOne { + return Err(error_response(StatusCode::BAD_REQUEST, "Complete part one first")); + } + + // Get the expected verification code for part two + let progress = challenge + .get_days_challenge(&did) + .await + .map_err(log_and_respond(StatusCode::INTERNAL_SERVER_ERROR, "Error loading challenge"))? + .ok_or_else(|| error_response(StatusCode::BAD_REQUEST, "Challenge not started"))?; + + let expected_code = progress.verification_code_two + .ok_or_else(|| error_response(StatusCode::BAD_REQUEST, "Part two not started"))?; + + let res = shared::advent::challenges::day_three::repo::inspect_car( + &car_bytes, + &("codes.advent.challenge.day".parse().unwrap()), + &("3".parse().unwrap()), + ) + .await; + + let record = match res { + Ok(r) => r, + Err(e) => { + log::warn!("bad uploaded car? {e}"); + set_flash_message( + &mut session, + "part_two_result", + FlashMessage::Error( + "Sorry, but your car failed inspection.".into() + ), + ) + .await + .map_err(log_and_respond(StatusCode::INTERNAL_SERVER_ERROR, "Session error"))?; + + return Ok(Redirect::to("/day/3#part_two")); + } + }; + + if !record.verification_code.eq_ignore_ascii_case(&expected_code) { + set_flash_message( + &mut session, + "part_two_result", + FlashMessage::Error("Your CAR looks nice! But not that verification code...".into()), + ) + .await + .map_err(log_and_respond(StatusCode::INTERNAL_SERVER_ERROR, "Session error"))?; + + return Ok(Redirect::to("/day/3#part_two")); + } + + challenge + .complete_part_two(did) + .await + .map_err(log_and_respond(StatusCode::INTERNAL_SERVER_ERROR, "Error completing"))?; + + set_flash_message( + &mut session, + "part_two_result", + FlashMessage::Success("You built a valid CAR file! Part 2 complete.".into()), + ) + .await + .map_err(log_and_respond(StatusCode::INTERNAL_SERVER_ERROR, "Session error"))?; + + // TODO: do something with their joke answer, if submitted + + Ok(Redirect::to("/day/3#part_two")) +} diff --git a/web/src/handlers/custom/mod.rs b/web/src/handlers/custom/mod.rs index 97fb326..85952da 100644 --- a/web/src/handlers/custom/mod.rs +++ b/web/src/handlers/custom/mod.rs @@ -1,3 +1,4 @@ +pub mod day_three; pub mod day_five; pub mod day_six; diff --git a/web/src/handlers/day.rs b/web/src/handlers/day.rs index 076a46b..9101c3d 100644 --- a/web/src/handlers/day.rs +++ b/web/src/handlers/day.rs @@ -88,12 +88,12 @@ fn pick_day( } } -pub(super) fn log_and_respond( +pub(super) fn log_and_respond( status: StatusCode, context: &'static str, ) -> impl FnOnce(E) -> Response { move |err| { - log::error!("{context}: {err}"); + log::error!("{context}: {err:?}"); error_response(status, context) } } diff --git a/web/src/main.rs b/web/src/main.rs index 678f571..f85dee9 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -286,6 +286,10 @@ async fn main() -> Result<(), Box> { false => post(handlers::day::post_day_handler), }, ) + .route( + "/day/3/upload-car", + post(handlers::custom::day_three::inspect_car), + ) .route( "/day/5/{user_did}", get(handlers::custom::day_five::create_record_handler),