challenge: 23

This commit is contained in:
itsscb 2024-12-25 23:06:28 +01:00
parent 91b18785c3
commit 0d1a1c152d
12 changed files with 593 additions and 22 deletions

View File

@ -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"]

2
Shuttle.toml Normal file
View File

@ -0,0 +1,2 @@
[build]
assets = ["assets/*"]

277
assets/23.html Normal file
View File

@ -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>

View File

@ -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"))
}

View File

@ -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())
}

View File

@ -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;

View File

@ -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())
);
}
}

View File

@ -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;

View File

@ -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()
}
)
}

View File

@ -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()
},
)
}

View File

@ -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>"#)
}

View File

@ -1,4 +1,4 @@
// #[cfg(feature = "task19")]
#[cfg(feature = "task19")]
mod task_nineteen {
use axum::http::StatusCode;
use axum_test::TestServer;