From 0d1a1c152d31aec834e09f0aeb86a0c8e63aacfd Mon Sep 17 00:00:00 2001 From: itsscb <dev@itsscb.de> Date: Wed, 25 Dec 2024 23:06:28 +0100 Subject: [PATCH] challenge: 23 --- Cargo.toml | 10 +- Shuttle.toml | 2 + assets/23.html | 277 ++++++++++++++++++++++++ src/lib.rs | 50 +++-- src/main.rs | 10 + src/routes/mod.rs | 3 + src/routes/task_twentythree/lockfile.rs | 100 +++++++++ src/routes/task_twentythree/mod.rs | 11 + src/routes/task_twentythree/ornament.rs | 64 ++++++ src/routes/task_twentythree/present.rs | 79 +++++++ src/routes/task_twentythree/star.rs | 7 + tests/task_nineteen/main.rs | 2 +- 12 files changed, 593 insertions(+), 22 deletions(-) create mode 100644 Shuttle.toml create mode 100644 assets/23.html create mode 100644 src/routes/task_twentythree/lockfile.rs create mode 100644 src/routes/task_twentythree/mod.rs create mode 100644 src/routes/task_twentythree/ornament.rs create mode 100644 src/routes/task_twentythree/present.rs create mode 100644 src/routes/task_twentythree/star.rs 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 @@ +<html> + <head> + <script src="https://unpkg.com/htmx.org@2.0.4"></script> + <style> +body { + --darkgrey: #0d0d0d; + --red: #a00; + --green: #060; + --white: #eee; + background-color: var(--darkgrey); + color: var(--white); +} +main { + max-width: 600px; + margin: auto; + margin-top: 100px; +} + +.tree { + position: relative; + height: 500px; +} +.tree * { + position: absolute; +} +#star { + top: -20px; + left: 150px; + width: 100px; + height: 100px; + background-color: darkgoldenrod; + clip-path: polygon( + 50% 0%, + 61% 35%, + 98% 35%, + 68% 57%, + 79% 91%, + 50% 70%, + 21% 91%, + 32% 57%, + 2% 35%, + 39% 35% + ); +} +#star.lit { + background: linear-gradient(10deg, rgba(227,233,0,1) 0%, rgba(241,245,148,1) 100%); +} +.tree-part { + width: 0; + height: 0; + border-style: solid; + border-color: transparent transparent green transparent; + border-width: 0 100px 100px 100px; +} +.tree-part1 { + left: 100px; + top: 20px; +} +.tree-part2 { + left: 100px; + top: 60px; + transform: scale(120%); + filter: brightness(90%); +} +.tree-part3 { + left: 100px; + top: 100px; + transform: scale(135%); +} +.tree-part4 { + left: 100px; + top: 140px; + transform: scale(150%); + filter: brightness(90%); +} +.tree-part5 { + left: 100px; + top: 180px; + transform: scale(160%); +} +.tree-base { + width: 40px; + height: 40px; + left: 180px; + top: 310px; + background-color: rgb(97, 47, 13); + margin: 0 auto; +} +.ornament { + width: 20px; + height: 20px; + background-color: darkred; + border-radius: 50%; + transition: background-color 1s ease; +} +.ornament.on { + background-color: red; +} +#ornament1 { + top: 90px; + left: 150px; +} +#ornament2 { + top: 140px; + left: 210px; +} +#ornament3 { + top: 172px; + left: 160px; +} +#ornament4 { + top: 253px; + left: 110px; +} +#ornament5 { + top: 200px; + left: 260px; +} +#ornament6 { + top: 230px; + left: 200px; +} +#ornament7 { + top: 270px; + left: 250px; +} +.present { + width: 100px; + height: 100px; + box-shadow: black 0px 0px 11px 1px; +} +.present.red { + background-color: red; +} +.present.blue { + background-color: blue; +} +.present.purple { + background-color: purple; +} +.present:nth-child(1) { + top: 280px; + left: 350px; +} +.present:nth-child(2) { + top: 280px; + left: 460px; +} +.present:nth-child(3) { + top: 320px; + left: 420px; +} +.present .ribbon { + background: rgb(227,233,0); + background: linear-gradient(0deg, rgba(227,233,0,1) 0%, rgba(241,245,148,1) 100%); +} +.present .ribbon:nth-child(1) { + width: 100px; + height: 20px; + top: 40px; +} +.present .ribbon:nth-child(2) { + width: 20px; + height: 100px; + left: 40px; +} +.present .ribbon:nth-child(3) { + width: 20px; + height: 30px; + left: 30px; + top: -20px; + transform: rotate(-45deg); +} +.present .ribbon:nth-child(4) { + width: 20px; + height: 30px; + left: 50px; + top: -20px; + transform: rotate(35deg); +} +#switch { + top: 400px; + left: 145px; + border: none; + background-color: #ccc; + color: black; + padding: 1em; + border-radius: .5em; + cursor: pointer; + font-weight: bold; +} +.text { + font-size: 200%; + font-weight: bold; + text-align: center; +} +.rocket { + width: 80px; + height: 80px; +} +.spacer { + height: 800px; +} +#lockfilecanvas { + height: 276px; + width: 276px; + margin: auto; + border: 1px solid grey; + margin-bottom: 100px; + position: relative; +} +#lockfilecanvas * { + position: absolute; + width: 20px; + height: 20px; + border-radius: 50%; +} + </style> + </head> + <body> + <main> + <div class="tree"> + <div class="present red" hx-get="/23/present/blue" hx-swap="outerHTML"> + <div class="ribbon"></div> + <div class="ribbon"></div> + <div class="ribbon"></div> + <div class="ribbon"></div> + </div> + <div class="present red" hx-get="/23/present/blue" hx-swap="outerHTML"> + <div class="ribbon"></div> + <div class="ribbon"></div> + <div class="ribbon"></div> + <div class="ribbon"></div> + </div> + <div class="present red" hx-get="/23/present/blue" hx-swap="outerHTML"> + <div class="ribbon"></div> + <div class="ribbon"></div> + <div class="ribbon"></div> + <div class="ribbon"></div> + </div> + <div class="tree-base"></div> + <div class="tree-part tree-part5"></div> + <div class="tree-part tree-part4"></div> + <div class="tree-part tree-part3"></div> + <div class="tree-part tree-part2"></div> + <div class="tree-part tree-part1"></div> + <div class="ornament" id="ornament1" hx-trigger="load changed delay:2000ms once" hx-get="/23/ornament/on/1" hx-swap="outerHTML"></div> + <div class="ornament" id="ornament2" hx-trigger="load changed delay:1600ms once" hx-get="/23/ornament/on/2" hx-swap="outerHTML"></div> + <div class="ornament" id="ornament3" hx-trigger="load changed delay:200ms once" hx-get="/23/ornament/on/3" hx-swap="outerHTML"></div> + <div class="ornament" id="ornament4" hx-trigger="load changed delay:400ms once" hx-get="/23/ornament/on/4" hx-swap="outerHTML"></div> + <div class="ornament" id="ornament5" hx-trigger="load changed delay:800ms once" hx-get="/23/ornament/on/5" hx-swap="outerHTML"></div> + <div class="ornament" id="ornament6" hx-trigger="load changed delay:1200ms once" hx-get="/23/ornament/on/6" hx-swap="outerHTML"></div> + <div class="ornament" id="ornament7" hx-trigger="load changed delay:1400ms once" hx-get="/23/ornament/on/7" hx-swap="outerHTML"></div> + <div class="present-wrap present-wrap1"></div> + <div class="present-wrap present-wrap2"></div> + <div class="present-wrap present-wrap3"></div> + <div class="present-wrap present-wrap4"></div> + <div id="star"></div> + <button id="switch" hx-get="/23/star" hx-swap="outerHTML" hx-target="#star"> + Light the star + </button> + </div> + <div class="text">Merry Christmas!</div> + <div class="text">/ Shuttle</div> + <div class="text"><img class="rocket" src="https://console.shuttle.dev/images/rocket.gif"></div> + <div class="spacer"></div> + <div class="text">Bonus task:</div> + <form hx-post="/23/lockfile" enctype="multipart/form-data" hx-target="#lockfilecanvas"> + <input type="file" name="lockfile" required> + <br> + <br> + <button type="submit">Submit lockfile</button> + </form> + <div id="lockfilecanvas"></div> + </main> + </body> +</html> 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<sqlx::PgPool>) -> 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<sqlx::PgPool>) -> 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<sqlx::PgPool>) -> 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<impl IntoResponse, (StatusCode, &'static str)> { + 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::<Value>(&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<String, &'static str> { + if hex::decode(checksum).is_err() { + return Err("Invalid checksum"); + } + + let color = checksum.chars().take(6).collect::<String>(); + let top = u32::from_str_radix(&checksum.chars().skip(6).take(2).collect::<String>(), 16) + .map_err(|_| "Invalid top value")?; + + let left = u32::from_str_radix(&checksum.chars().skip(8).take(2).collect::<String>(), 16) + .map_err(|_| "Invalid top value")?; + + Ok(format!( + r#"<div style="background-color:#{color};top:{top}px;left:{left}px;"></div>"# + )) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_checksum_to_sprinkle() { + assert_eq!( + checksum_to_sprinkle( + "337789faa0372648a8ac286b2f92a53121fe118f12e29009ac504872a5413cc6" + ), + Ok(r#"<div style="background-color:#337789;top:250px;left:160px;"></div>"#.to_string()) + ); + assert_eq!( + checksum_to_sprinkle( + "22ba454b13e4e29b5b892a62c334360a571de5a25c936283416c94328427dd57" + ), + Ok(r#"<div style="background-color:#22ba45;top:75px;left:19px;"></div>"#.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<String> for State { + type Error = &'static str; + + fn try_from(s: String) -> Result<Self, Self::Error> { + 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#"<div class="ornament{on}" id="ornament{id}" hx-trigger="load delay:2s once" hx-get="/23/ornament/{next_state}/{id}" hx-swap="outerHTML"></div>"#,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<String> for Color { + type Error = &'static str; + + fn try_from(s: String) -> Result<Self, Self::Error> { + match s.to_lowercase().as_str() { + "blue" => Ok(Self::Blue), + "purple" => Ok(Self::Purple), + "red" => Ok(Self::Red), + _ => Err("invalid color"), + } + } +} + +// impl From<String> 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<String>) -> impl IntoResponse { + Color::try_from(color).map_or_else( + |_| (StatusCode::IM_A_TEAPOT, "I'm a teapot").into_response(), + |color| { + Html(format!( + r#"<div class="present {}" hx-get="/23/present/{}" hx-swap="outerHTML"> + <div class="ribbon"></div> + <div class="ribbon"></div> + <div class="ribbon"></div> + <div class="ribbon"></div> + </div>"#, + 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#"<div id="star" class="lit"></div>"#) +} 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;