From 665e4cbe3c31f34523d8aa2e05486dec3c5bc092 Mon Sep 17 00:00:00 2001 From: Bailey Townsend Date: Thu, 19 Mar 2026 21:18:21 -0500 Subject: [PATCH] day 5 part 1 --- shared/challenges_markdown/five/part_one.md | 17 ++- shared/lexicons/codes/advent/shhh.json | 31 +++++ shared/src/advent/challenges/day_five.rs | 124 +++++++++++++++++- shared/src/lexicons/codes/advent/challenge.rs | 7 + .../lexicons/codes/advent/challenge/shhh.rs | 16 +++ shared/src/lexicons/record.rs | 16 +++ web/src/handlers/auth.rs | 10 +- web/src/handlers/day.rs | 40 ++++++ web/src/main.rs | 22 ++++ 9 files changed, 274 insertions(+), 9 deletions(-) create mode 100644 shared/lexicons/codes/advent/shhh.json create mode 100644 shared/src/lexicons/codes/advent/challenge/shhh.rs diff --git a/shared/challenges_markdown/five/part_one.md b/shared/challenges_markdown/five/part_one.md index 640ea85..e17c4e1 100644 --- a/shared/challenges_markdown/five/part_one.md +++ b/shared/challenges_markdown/five/part_one.md @@ -1,2 +1,15 @@ -Day five is a only one part one. We talk about jetstream/firehose. A secret account creates a record and they have to -watch create/update to capture the verification code. We hav an added button when clicked it creates the record. \ No newline at end of file +All events on the atmosphere are broadcast as events on +the [Event Stream](https://atproto.com/specs/event-stream). Every lexicon you create is sent from a websocket +on your PDS, picked up by a relay and then distributed to the rest of the network. This is usually referred to as +the [firehose](https://docs.bsky.app/docs/advanced-guides/firehose). There is also +the [jetstream](https://atproto.com/blog/jetstream) that simplifies the firehose by broadcasting the events in json and +strips the authenticated portion to keep things simple. The jetstream also has the advantage of allowing you to filter +by `collection` so you only get the events you are interested in. + +Today's challenge will be to find a record that is created by a secret account. When you click the link below our secret +account will create a `codes.advent.challenge.shhh` record with your did as the `subject` field and will hold today's +verificaiton code to enter below. This will come across as a create or update event and you can click the button below +to create the record as many times as you need. + +Create the record (will create as many as you need) + diff --git a/shared/lexicons/codes/advent/shhh.json b/shared/lexicons/codes/advent/shhh.json new file mode 100644 index 0000000..6d4a84f --- /dev/null +++ b/shared/lexicons/codes/advent/shhh.json @@ -0,0 +1,31 @@ +{ + "lexicon": 1, + "id": "codes.advent.challenge.shhh", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": [ + "secretPartOne", + "subject", + "createdAt" + ], + "properties": { + "secretPartOne": { + "type": "string" + }, + "subject": { + "type": "string", + "format": "did" + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } +} \ No newline at end of file diff --git a/shared/src/advent/challenges/day_five.rs b/shared/src/advent/challenges/day_five.rs index ca5fd79..e45048c 100644 --- a/shared/src/advent/challenges/day_five.rs +++ b/shared/src/advent/challenges/day_five.rs @@ -1,12 +1,88 @@ -use crate::OAuthAgentType; use crate::advent::day::Day; -use crate::advent::{AdventChallenge, AdventError, ChallengeCheckResponse}; +use crate::advent::{AdventChallenge, AdventError, AdventPart, ChallengeCheckResponse}; +use crate::lexicons::codes::advent; +use crate::lexicons::record::KnownRecord; +use crate::{OAuthAgentType, PasswordAgent}; use async_trait::async_trait; +use atrium_api::types::Collection; +use atrium_api::types::string::Tid; +use serde_json::json; use sqlx::PgPool; pub struct DayFive { pub pool: PgPool, pub oauth_client: Option, + pub secret_agent: Option, +} + +impl DayFive { + /// Creates the challenge record on the secret agent's repo with the user's verification code. + /// This is called from the `/day/5/{did}` endpoint. + pub async fn create_secret_record(&self, did: &str) -> Result<(), AdventError> { + let Some(agent) = &self.secret_agent else { + log::warn!("No secret agent configured, skipping record creation for day five"); + return Err(AdventError::ShouldNotHappen( + "No secret agent configured".to_string(), + )); + }; + + // Get the user's challenge to find their verification code + let challenge = self.get_days_challenge(did).await?.ok_or_else(|| { + AdventError::ShouldNotHappen("Could not find challenge record for day 5".to_string()) + })?; + + let code = challenge.verification_code_one.ok_or_else(|| { + AdventError::ShouldNotHappen( + "No verification code found for day 5 challenge".to_string(), + ) + })?; + + let agent_did = agent + .did() + .await + .ok_or_else(|| AdventError::ShouldNotHappen("Secret agent has no DID".to_string()))?; + + let record_data = advent::challenge::shhh::RecordData { + secret_part_one: code, + created_at: atrium_api::types::string::Datetime::now(), + subject: did.parse().unwrap(), + }; + let known_record: KnownRecord = record_data.into(); + let record_value: atrium_api::types::Unknown = known_record.into(); + + let tid = Tid::from_datetime(23.try_into().unwrap(), challenge.time_started); + let result = agent + .api + .com + .atproto + .repo + .put_record( + atrium_api::com::atproto::repo::put_record::InputData { + collection: advent::challenge::Shhh::NSID.parse().unwrap(), + repo: agent_did.as_ref().parse().unwrap(), + rkey: tid.as_ref().parse().unwrap(), + swap_record: None, + record: record_value, + swap_commit: None, + validate: Some(false), + } + .into(), + ) + .await; + + match result { + Ok(_) => { + log::info!("Created secret record for day 5 for user: {did}"); + Ok(()) + } + Err(e) => { + log::error!("Failed to create secret record for day 5: {e}"); + Err(AdventError::ShouldNotHappen(format!( + "Failed to create secret record: {e}" + ))) + } + } + } } #[async_trait] @@ -27,11 +103,49 @@ impl AdventChallenge for DayFive { true } + async fn build_additional_context( + &self, + did: &str, + part: &AdventPart, + _code: &str, + ) -> Result, AdventError> { + match part { + AdventPart::One => Ok(Some(json!({ "did": did }))), + AdventPart::Two => Ok(None), + } + } + async fn check_part_one( &self, - _did: String, - _verification_code: Option, + did: String, + verification_code: Option, ) -> Result { - todo!() + let submitted_code = match verification_code { + Some(code) if !code.is_empty() => code, + _ => { + return Ok(ChallengeCheckResponse::Incorrect( + "Please enter a verification code".to_string(), + )); + } + }; + + let Some(challenge) = self.get_days_challenge(&did).await? else { + log::error!("Could not find a challenge record for day: 5 for the user: {did:?}"); + return Err(AdventError::ShouldNotHappen( + "Could not find challenge record".to_string(), + )); + }; + + let expected_code = challenge + .verification_code_one + .ok_or(AdventError::ShouldNotHappen( + "no verification code for day 5 challenge".to_string(), + ))?; + + Ok(if submitted_code == expected_code { + ChallengeCheckResponse::Correct + } else { + ChallengeCheckResponse::Incorrect(format!("The code {} is incorrect", submitted_code)) + }) } } diff --git a/shared/src/lexicons/codes/advent/challenge.rs b/shared/src/lexicons/codes/advent/challenge.rs index dbff21f..00a7365 100644 --- a/shared/src/lexicons/codes/advent/challenge.rs +++ b/shared/src/lexicons/codes/advent/challenge.rs @@ -1,9 +1,16 @@ // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. //!Definitions for the `codes.advent.challenge` namespace. pub mod day; +pub mod shhh; #[derive(Debug)] pub struct Day; impl atrium_api::types::Collection for Day { const NSID: &'static str = "codes.advent.challenge.day"; type Record = day::Record; } +#[derive(Debug)] +pub struct Shhh; +impl atrium_api::types::Collection for Shhh { + const NSID: &'static str = "codes.advent.challenge.shhh"; + type Record = shhh::Record; +} diff --git a/shared/src/lexicons/codes/advent/challenge/shhh.rs b/shared/src/lexicons/codes/advent/challenge/shhh.rs new file mode 100644 index 0000000..2e76138 --- /dev/null +++ b/shared/src/lexicons/codes/advent/challenge/shhh.rs @@ -0,0 +1,16 @@ +// @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. +//!Definitions for the `codes.advent.challenge.shhh` namespace. +use atrium_api::types::TryFromUnknown; +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RecordData { + pub created_at: atrium_api::types::string::Datetime, + pub secret_part_one: String, + pub subject: atrium_api::types::string::Did, +} +pub type Record = atrium_api::types::Object; +impl From for RecordData { + fn from(value: atrium_api::types::Unknown) -> Self { + Self::try_from_unknown(value).unwrap() + } +} diff --git a/shared/src/lexicons/record.rs b/shared/src/lexicons/record.rs index 97f51c4..6680d78 100644 --- a/shared/src/lexicons/record.rs +++ b/shared/src/lexicons/record.rs @@ -7,6 +7,10 @@ pub enum KnownRecord { LexiconsCodesAdventChallengeDay( Box, ), + #[serde(rename = "codes.advent.challenge.shhh")] + LexiconsCodesAdventChallengeShhh( + Box, + ), } impl From for KnownRecord { fn from(record: crate::lexicons::codes::advent::challenge::day::Record) -> Self { @@ -20,6 +24,18 @@ impl From for KnownR KnownRecord::LexiconsCodesAdventChallengeDay(Box::new(record_data.into())) } } +impl From for KnownRecord { + fn from(record: crate::lexicons::codes::advent::challenge::shhh::Record) -> Self { + KnownRecord::LexiconsCodesAdventChallengeShhh(Box::new(record)) + } +} +impl From for KnownRecord { + fn from( + record_data: crate::lexicons::codes::advent::challenge::shhh::RecordData, + ) -> Self { + KnownRecord::LexiconsCodesAdventChallengeShhh(Box::new(record_data.into())) + } +} impl Into for KnownRecord { fn into(self) -> atrium_api::types::Unknown { atrium_api::types::TryIntoUnknown::try_into_unknown(&self).unwrap() diff --git a/web/src/handlers/auth.rs b/web/src/handlers/auth.rs index 9033b47..e643903 100644 --- a/web/src/handlers/auth.rs +++ b/web/src/handlers/auth.rs @@ -125,8 +125,14 @@ pub async fn logout_handler( None => {} Some(did) => { //TODO lots of unwraps - let did = atrium_api::types::string::Did::new(did.clone()).unwrap(); - let client = oauth_client.restore(&did).await.unwrap(); + let did = atrium_api::types::string::Did::new(did.clone()).map_err(|err| { + log::error!("Failed to parse DID: {err}"); + error_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to log out") + })?; + let client = oauth_client.restore(&did).await.map_err(|err| { + log::error!("Failed to restore OAuth client: {err}"); + error_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to log out") + })?; let agent = Agent::new(client); let _ = agent.api.com.atproto.server.delete_session().await; } diff --git a/web/src/handlers/day.rs b/web/src/handlers/day.rs index cf04ce0..63b365d 100644 --- a/web/src/handlers/day.rs +++ b/web/src/handlers/day.rs @@ -50,6 +50,7 @@ fn pick_day( Day::Five => Ok(Box::new(DayFive { pool: state.postgres_pool, oauth_client, + secret_agent: state.secret_agent.clone(), })), Day::Six => Ok(Box::new(DaySix { pool: state.postgres_pool, @@ -448,3 +449,42 @@ pub async fn post_day_handler( } } } + +/// Endpoint for day 5: creates a record on the secret agent's repo with the user's verification code, +/// then redirects back to /day/5 so the user can find the code via firehose/jetstream. +pub async fn day_five_create_record_handler( + Path(user_did): Path, + state: State, + session: AxumSessionStore, +) -> Result { + // Verify the user is logged in and the DID matches + let session_did = session.get_did().ok_or_else(|| { + error_response( + StatusCode::FORBIDDEN, + "You need to be logged in to do this", + ) + })?; + + if session_did != user_did { + return Err(error_response( + StatusCode::FORBIDDEN, + "You can only trigger this for your own account", + )); + } + + let day_five = DayFive { + pool: state.postgres_pool.clone(), + oauth_client: None, + secret_agent: state.secret_agent.clone(), + }; + + day_five + .create_secret_record(&user_did) + .await + .map_err(log_and_respond( + StatusCode::INTERNAL_SERVER_ERROR, + "Error creating the secret record", + ))?; + + Ok(Redirect::to("/day/5")) +} diff --git a/web/src/main.rs b/web/src/main.rs index e128cae..650338a 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -72,6 +72,7 @@ struct AppState { //Used to get did to handle leaving because I figured we'd need it handle_resolver: HandleResolver, challenge_agent: Option, + secret_agent: Option, } pub fn oauth_scopes() -> Vec { @@ -239,13 +240,30 @@ async fn main() -> Result<(), Box> { challenge_agent = Some(Arc::new(agent)); } + // secret challenge account + let mut secret_challenge_agent = None; + let secret_challenge_pds = env::var("SECRET_CHALLENGE_PDS"); + let secret_challenge_identity = env::var("SECRET_CHALLENGE_IDENTITY"); + let secret_challenge_password = env::var("SECRET_CHALLENGE_PASSWORD"); + if let (Ok(pds), Ok(identity), Ok(password)) = ( + secret_challenge_pds, + secret_challenge_identity, + secret_challenge_password, + ) { + let agent = AtpAgent::new(ReqwestClient::new(pds), MemorySessionStore::default()); + agent.login(identity, password).await?; + secret_challenge_agent = Some(Arc::new(agent)); + } + let app_state = AppState { postgres_pool, redis_pool, oauth_client: client, handle_resolver, challenge_agent, + secret_agent: secret_challenge_agent, }; + //HACK Yeah I don't like it either - bt let prod: bool = env::var("PROD") .map(|val| val == "true") @@ -272,6 +290,10 @@ async fn main() -> Result<(), Box> { false => post(handlers::day::post_day_handler), }, ) + .route( + "/day/5/{user_did}", + get(handlers::day::day_five_create_record_handler), + ) .route("/login", get(handlers::auth::login_page_handler)) .route("/logout", get(handlers::auth::logout_handler)) .route("/redirect/login", get(handlers::auth::login_handle)) -- 2.51.2