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;