diff --git a/Cargo.toml b/Cargo.toml index bc66c97..9ef1b53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,14 +29,18 @@ sqlx = { version = "0.8.2", features = [ "migrate", ] } uuid = { version = "1.11.0", features = ["v4"] } +tower-http = { version = "0.6.2", features = ["fs"], optional = true } +html-escape = "0.2.13" +regex = "1.11.1" +hex = "0.4.3" [dev-dependencies] axum-test = "16.4.0" [features] -default = ["shuttle-shared-db"] +default = ["tower-http", "toml"] task1-9 = ["cargo-manifest", "serde_yml", "toml"] task12 = ["rand"] task16 = ["jsonwebtoken"] -task19 = [] -task23 = [] +task19 = ["shuttle-shared-db"] +task23 = ["tower-http", "toml"] diff --git a/Shuttle.toml b/Shuttle.toml new file mode 100644 index 0000000..6510119 --- /dev/null +++ b/Shuttle.toml @@ -0,0 +1,2 @@ +[build] +assets = ["assets/*"] diff --git a/assets/23.html b/assets/23.html new file mode 100644 index 0000000..b0bdedb --- /dev/null +++ b/assets/23.html @@ -0,0 +1,277 @@ + + + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
Merry Christmas!
+
/ Shuttle
+
+
+
Bonus task:
+
+ +
+
+ +
+
+
+ + diff --git a/src/lib.rs b/src/lib.rs index 3166b4e..a191cd6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,6 @@ mod routes; -use axum::routing::{delete, put}; +#[cfg(feature = "task19")] use routes::task_nineteen::{cite, draft, list, remove, reset, undo}; #[cfg(feature = "task12")] use routes::{board, place, random_board, reset, Board}; @@ -11,19 +11,37 @@ use routes::{ hello_bird, hello_world, ipv4_dest, ipv4_key, ipv6_dest, ipv6_key, manifest, milk, minus_one, refill, MilkFactory, }; +use tower_http::services::ServeDir; +#[allow(unused_imports)] +use axum::{ + routing::{delete, get, post, put}, + Router, +}; +#[cfg(feature = "task19")] #[allow(unused_imports)] pub fn router(pool: Option) -> axum::Router { - use axum::{ - routing::{get, post}, - Router, - }; + return pool.map_or_else(Router::new, |pool| { + Router::new() + .route("/19/reset", post(reset)) + .route("/19/draft", post(draft)) + .route("/19/undo/:id", put(undo)) + .route("/19/remove/:id", delete(remove)) + .route("/19/cite/:id", get(cite)) + .route("/19/list", get(list)) + .with_state(pool) + }); +} + +#[cfg(not(feature = "task19"))] +pub fn router() -> axum::Router { + use routes::task_twentythree::{lockfile, ornament, present, star}; #[cfg(feature = "task1-9")] let milk_factory = MilkFactory::new(); #[cfg(feature = "task1-9")] - return Router::new() + return return Router::new() .route("/hello_world", get(hello_world)) .route("/-1/seek", get(minus_one)) .route("/2/dest", get(ipv4_dest)) @@ -37,7 +55,7 @@ pub fn router(pool: Option) -> axum::Router { .route("/", get(hello_bird)); #[cfg(feature = "task12")] - Router::new() + return Router::new() .route("/12/board", get(board)) .route("/12/reset", post(reset)) .route("/12/place/:team/:column", post(place)) @@ -45,19 +63,15 @@ pub fn router(pool: Option) -> axum::Router { .with_state(Board::new()); #[cfg(feature = "task16")] - Router::new() + return Router::new() .route("/16/wrap", post(wrap)) .route("/16/unwrap", get(unwrap)) .route("/16/decode", post(decode)); - pool.map_or_else(Router::new, |pool| { - Router::new() - .route("/19/reset", post(reset)) - .route("/19/draft", post(draft)) - .route("/19/undo/:id", put(undo)) - .route("/19/remove/:id", delete(remove)) - .route("/19/cite/:id", get(cite)) - .route("/19/list", get(list)) - .with_state(pool) - }) + Router::new() + .route("/23/star", get(star)) + .route("/23/lockfile", post(lockfile)) + .route("/23/present/:color", get(present)) + .route("/23/ornament/:state/:id", get(ornament)) + .nest_service("/assets/", ServeDir::new("assets")) } diff --git a/src/main.rs b/src/main.rs index 880008e..c672b36 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ async fn main() -> shuttle_axum::ShuttleAxum { Ok(router.into()) } +#[cfg(feature = "task19")] #[shuttle_runtime::main] #[allow(clippy::unused_async)] async fn main(#[shuttle_shared_db::Postgres] pool: sqlx::PgPool) -> shuttle_axum::ShuttleAxum { @@ -21,3 +22,12 @@ async fn main(#[shuttle_shared_db::Postgres] pool: sqlx::PgPool) -> shuttle_axum Ok(router.into()) } + +#[cfg(not(feature = "task19"))] +#[shuttle_runtime::main] +#[allow(clippy::unused_async)] +async fn main() -> shuttle_axum::ShuttleAxum { + let router = router(); + + Ok(router.into()) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index b9607ef..b317467 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -8,8 +8,11 @@ mod task_sixteen; #[cfg(feature = "task16")] pub use task_sixteen::{decode, unwrap, wrap}; +#[cfg(feature = "task19")] pub mod task_nineteen; +pub mod task_twentythree; + #[cfg(feature = "task1-9")] mod hello_bird; diff --git a/src/routes/task_twentythree/lockfile.rs b/src/routes/task_twentythree/lockfile.rs new file mode 100644 index 0000000..4424015 --- /dev/null +++ b/src/routes/task_twentythree/lockfile.rs @@ -0,0 +1,100 @@ +use axum::{ + http::StatusCode, + response::{Html, IntoResponse}, +}; +use axum_extra::extract::Multipart; +use regex::Regex; +use toml::Value; +use tracing::{error, info, instrument}; + +#[instrument()] +pub async fn lockfile( + mut multipart: Multipart, +) -> Result { + let mut content = String::new(); + while let Some(field) = multipart.next_field().await.map_err(|_| { + error!("Failed to read field from multipart"); + ( + StatusCode::BAD_REQUEST, + "Failed to read field from multipart", + ) + })? { + let data = field.bytes().await.map_err(|_| { + error!("Failed to read bytes from multipart"); + ( + StatusCode::BAD_REQUEST, + "Failed to read bytes from multipart", + ) + })?; + + content.push_str(&String::from_utf8_lossy(&data)); + } + + if toml::from_str::(&content).is_err() { + error!("Failed to parse TOML from multipart"); + return Err(( + StatusCode::BAD_REQUEST, + "Failed to parse TOML from multipart", + )); + } + + let re = Regex::new(r#"checksum = "([^"]+)"\n"#) + .map_err(|_| (StatusCode::BAD_REQUEST, "Failed to compile regex"))?; + + let mut output = Vec::new(); + for cap in re.captures_iter(&content) { + if let Some(m) = cap.get(1) { + let checksum = m.as_str(); + match checksum_to_sprinkle(checksum) { + Ok(sprinkle) => output.push(sprinkle), + Err(_) => return Err((StatusCode::UNPROCESSABLE_ENTITY, "Invalid checksum")), + } + } + } + + if output.is_empty() { + error!("No checksums found"); + return Err((StatusCode::BAD_REQUEST, "No checksums found")); + } + + info!(output = ?output); + Ok(Html(output.join("\n")).into_response()) +} + +fn checksum_to_sprinkle(checksum: &str) -> Result { + if hex::decode(checksum).is_err() { + return Err("Invalid checksum"); + } + + let color = checksum.chars().take(6).collect::(); + let top = u32::from_str_radix(&checksum.chars().skip(6).take(2).collect::(), 16) + .map_err(|_| "Invalid top value")?; + + let left = u32::from_str_radix(&checksum.chars().skip(8).take(2).collect::(), 16) + .map_err(|_| "Invalid top value")?; + + Ok(format!( + r#"
"# + )) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_checksum_to_sprinkle() { + assert_eq!( + checksum_to_sprinkle( + "337789faa0372648a8ac286b2f92a53121fe118f12e29009ac504872a5413cc6" + ), + Ok(r#"
"#.to_string()) + ); + assert_eq!( + checksum_to_sprinkle( + "22ba454b13e4e29b5b892a62c334360a571de5a25c936283416c94328427dd57" + ), + Ok(r#"
"#.to_string()) + ); + } +} diff --git a/src/routes/task_twentythree/mod.rs b/src/routes/task_twentythree/mod.rs new file mode 100644 index 0000000..210020e --- /dev/null +++ b/src/routes/task_twentythree/mod.rs @@ -0,0 +1,11 @@ +mod star; +pub use star::star; + +mod present; +pub use present::present; + +mod ornament; +pub use ornament::ornament; + +mod lockfile; +pub use lockfile::lockfile; diff --git a/src/routes/task_twentythree/ornament.rs b/src/routes/task_twentythree/ornament.rs new file mode 100644 index 0000000..56ebca7 --- /dev/null +++ b/src/routes/task_twentythree/ornament.rs @@ -0,0 +1,64 @@ +use std::fmt::Display; + +use axum::{ + extract::Path, + http::StatusCode, + response::{Html, IntoResponse}, +}; +use html_escape::encode_safe; +use tracing::{info, instrument}; + +#[derive(Debug, PartialEq, Clone)] +enum State { + On, + Off, +} + +impl State { + const fn next(&self) -> Self { + match self { + Self::On => Self::Off, + Self::Off => Self::On, + } + } + + const fn is_on(&self) -> &str { + match self { + Self::On => " on", + Self::Off => "", + } + } +} + +impl TryFrom for State { + type Error = &'static str; + + fn try_from(s: String) -> Result { + match s.to_lowercase().as_str() { + "on" => Ok(Self::On), + "off" => Ok(Self::Off), + _ => Err("invalid state"), + } + } +} + +impl Display for State { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let state = match self { + Self::On => "on", + Self::Off => "off", + }; + write!(f, "{state}") + } +} + +#[instrument()] +pub async fn ornament(Path((state, id)): Path<(String, String)>) -> impl IntoResponse { + State::try_from(state).map_or_else(|_| (StatusCode::IM_A_TEAPOT, "I'm a teapot").into_response(), + |state| { + let ornament = format!(r#"
"#,on = state.is_on(), id = encode_safe(&id), next_state = state.next() ); + info!(ornament); + Html(ornament).into_response() + } + ) +} diff --git a/src/routes/task_twentythree/present.rs b/src/routes/task_twentythree/present.rs new file mode 100644 index 0000000..1176244 --- /dev/null +++ b/src/routes/task_twentythree/present.rs @@ -0,0 +1,79 @@ +use std::fmt::Display; + +use axum::{ + extract::Path, + http::StatusCode, + response::{Html, IntoResponse}, +}; +use tracing::instrument; + +#[derive(Debug, PartialEq, Clone)] +enum Color { + Red, + Blue, + Purple, +} + +impl Color { + const fn next(&self) -> Self { + match self { + Self::Red => Self::Blue, + Self::Blue => Self::Purple, + Self::Purple => Self::Red, + } + } +} + +impl Display for Color { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let color = match self { + Self::Red => "red", + Self::Blue => "blue", + Self::Purple => "purple", + }; + write!(f, "{color}") + } +} + +impl TryFrom for Color { + type Error = &'static str; + + fn try_from(s: String) -> Result { + match s.to_lowercase().as_str() { + "blue" => Ok(Self::Blue), + "purple" => Ok(Self::Purple), + "red" => Ok(Self::Red), + _ => Err("invalid color"), + } + } +} + +// impl From for Color { +// fn from(s: String) -> Self { +// match s.to_lowercase().as_str() { +// "blue" => Self::Blue, +// "purple" => Self::Purple, +// _ => Self::Red, +// } +// } +// } + +#[instrument()] +pub async fn present(Path(color): Path) -> impl IntoResponse { + Color::try_from(color).map_or_else( + |_| (StatusCode::IM_A_TEAPOT, "I'm a teapot").into_response(), + |color| { + Html(format!( + r#"
+
+
+
+
+
"#, + color, + color.next() + )) + .into_response() + }, + ) +} diff --git a/src/routes/task_twentythree/star.rs b/src/routes/task_twentythree/star.rs new file mode 100644 index 0000000..43f58ca --- /dev/null +++ b/src/routes/task_twentythree/star.rs @@ -0,0 +1,7 @@ +use axum::response::{Html, IntoResponse}; +use tracing::instrument; + +#[instrument()] +pub async fn star() -> impl IntoResponse { + Html(r#"
"#) +} diff --git a/tests/task_nineteen/main.rs b/tests/task_nineteen/main.rs index 33afd36..6078b7e 100644 --- a/tests/task_nineteen/main.rs +++ b/tests/task_nineteen/main.rs @@ -1,4 +1,4 @@ -// #[cfg(feature = "task19")] +#[cfg(feature = "task19")] mod task_nineteen { use axum::http::StatusCode; use axum_test::TestServer;