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),