diff --git a/Cargo.lock b/Cargo.lock index 3136b71..4623246 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,6 +25,7 @@ checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8" dependencies = [ "axum-core", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", @@ -41,11 +42,13 @@ dependencies = [ "serde", "serde_json", "serde_path_to_error", + "serde_urlencoded", "sync_wrapper", "tokio", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -65,6 +68,29 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fc6f625a1f7705c6cf62d0d070794e94668988b1c38111baeec177c715f7b" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "serde", + "tower", + "tower-layer", + "tower-service", ] [[package]] @@ -94,12 +120,41 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -276,6 +331,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "object" version = "0.36.7" @@ -309,6 +370,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.93" @@ -332,6 +399,7 @@ name = "redstrate-vote-system" version = "0.1.0" dependencies = [ "axum", + "axum-extra", "serde", "serde_json", "tokio", @@ -399,6 +467,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -451,6 +531,37 @@ dependencies = [ "once_cell", ] +[[package]] +name = "time" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tokio" version = "1.43.0" @@ -540,6 +651,12 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index 7ae4b1e..1404d3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,8 @@ codegen-units = 1 panic = "abort" [dependencies] -axum = { version = "0.8", features = ["json", "tokio", "http1"], default-features = false } +axum = { version = "0.8", features = ["json", "tokio", "http1", "form"], default-features = false } +axum-extra = { version = "0.10", features = ["cookie"] } serde_json = { version = "1.0", default-features = false } tokio = { version = "1.32", features = ["macros", "rt", "rt-multi-thread"], default-features = false } tracing = { version = "0.1", default-features = false } diff --git a/src/main.rs b/src/main.rs index d4ecdb7..be3204a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,7 @@ use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::{AppendHeaders, Html, IntoResponse, Redirect, Response}; +use axum::Form; use axum::{ routing::{get, post}, Json, Router, @@ -6,8 +9,8 @@ use axum::{ use serde::{Deserialize, Serialize}; use std::net::SocketAddr; use std::sync::{Arc, Mutex}; -use axum::http::StatusCode; -use axum::response::{IntoResponse, Response}; +use axum::http::header::SET_COOKIE; +use axum_extra::extract::CookieJar; #[derive(Debug, Clone, Serialize, Deserialize)] struct Page { @@ -40,7 +43,7 @@ struct PollRecord { #[derive(Clone)] struct AppState { record: Arc>, - poll_votes: Arc> + poll_votes: Arc>, } impl AppState { @@ -51,19 +54,13 @@ impl AppState { fn save_polls(&self) { let st = self.poll_votes.lock().unwrap().to_owned(); - serde_json::to_writer( - &std::fs::File::create("pollvotes.json").unwrap(), - &st, - ) + serde_json::to_writer(&std::fs::File::create("pollvotes.json").unwrap(), &st) .expect("failed to write poll votes!"); } fn save_votes(&self) { let st = self.record.lock().unwrap().to_owned(); - serde_json::to_writer( - &std::fs::File::create("votes.json").unwrap(), - &st, - ) + serde_json::to_writer(&std::fs::File::create("votes.json").unwrap(), &st) .expect("failed to write votes!"); } @@ -106,17 +103,18 @@ impl AppState { } } } - + fn get_poll_config_by_id(id: i32) -> Option { if let Ok(data) = std::fs::read_to_string("polls.json") { if let Ok(config) = serde_json::from_str::(&data) { - let current_poll: Vec<&PollConfig> = config.polls.iter().filter(|poll| poll.id == id).collect(); + let current_poll: Vec<&PollConfig> = + config.polls.iter().filter(|poll| poll.id == id).collect(); if !current_poll.is_empty() { return Some(<&PollConfig>::clone(current_poll.first().unwrap()).clone()); } } } - + None } @@ -179,7 +177,7 @@ struct PollChoiceConfig { struct PollConfig { id: i32, title: String, - choices: Vec + choices: Vec, } #[derive(Debug, Clone, Deserialize)] @@ -193,13 +191,13 @@ struct PollChoiceResponse { id: i32, name: String, url: String, - votes: i32 + votes: i32, } #[derive(Debug, Clone, Serialize)] struct PollResponse { title: String, - choices: Vec + choices: Vec, } async fn view_current_poll(State(state): State) -> Response { @@ -208,7 +206,11 @@ async fn view_current_poll(State(state): State) -> Response { if let Ok(data) = std::fs::read_to_string("polls.json") { if let Ok(config) = serde_json::from_str::(&data) { if let Some(current_poll) = config.current_poll { - let current_poll: Vec<&PollConfig> = config.polls.iter().filter(|poll| poll.id == current_poll).collect(); + let current_poll: Vec<&PollConfig> = config + .polls + .iter() + .filter(|poll| poll.id == current_poll) + .collect(); if !current_poll.is_empty() { if let Some(poll) = state.get_poll_votes_by_poll_id(current_poll[0].id) { let poll_config = current_poll.first().unwrap(); @@ -244,34 +246,238 @@ async fn view_current_poll(State(state): State) -> Response { return Json(PollResponse { title: current_poll.first().unwrap().title.clone(), - choices - }).into_response(); + choices, + }) + .into_response(); } } } } } - ( - StatusCode::NOT_FOUND, - "No poll is open right now!", - ).into_response() + (StatusCode::NOT_FOUND, "No poll is open right now!").into_response() } -async fn vote_current_poll(State(state): State, Path(id): Path) { +#[derive(Deserialize)] +struct SubmitForm { + choice: i32, +} + +async fn vote_current_poll(State(state): State, Form(form): Form) -> Response { + let id = form.choice; tracing::info!("Submitting vote for choice {id}"); if let Ok(data) = std::fs::read_to_string("polls.json") { if let Ok(config) = serde_json::from_str::(&data) { if let Some(current_poll) = config.current_poll { - let current_poll: Vec<&PollConfig> = config.polls.iter().filter(|poll| poll.id == current_poll).collect(); + let current_poll: Vec<&PollConfig> = config + .polls + .iter() + .filter(|poll| poll.id == current_poll) + .collect(); if !current_poll.is_empty() { state.vote_poll(current_poll.first().unwrap().id, id); state.save_polls(); + + let headers = AppendHeaders([(SET_COOKIE, format!("poll_{}={}", current_poll.first().unwrap().id, id))]); + + // make sure to redirect the iframe back to results + return (headers, Redirect::to("/polls/frame/results")).into_response(); } } } } + + (StatusCode::NOT_FOUND, "No poll is open right now!").into_response() +} + +async fn poll_frame_vote(State(state): State, jar: CookieJar) -> Html { + tracing::info!("Requesting current poll"); + if let Ok(data) = std::fs::read_to_string("polls.json") { + if let Ok(config) = serde_json::from_str::(&data) { + if let Some(current_poll) = config.current_poll { + let current_poll: Vec<&PollConfig> = config + .polls + .iter() + .filter(|poll| poll.id == current_poll) + .collect(); + if !current_poll.is_empty() { + if let Some(poll) = state.get_poll_votes_by_poll_id(current_poll[0].id) { + let poll_config = current_poll.first().unwrap(); + let mut choices = vec![]; + + let find_vote_choice = |id: i32| { + for choice in &poll.choices { + if choice.id == id { + return Some(choice); + } + } + + return None; + }; + + for choice in &poll_config.choices { + if let Some(vote_choice) = find_vote_choice(choice.id) { + choices.push(PollChoiceResponse { + id: choice.id, + name: choice.name.clone(), + url: choice.url.clone(), + votes: vote_choice.votes, + }); + } else { + choices.push(PollChoiceResponse { + id: choice.id, + name: choice.name.clone(), + url: choice.url.clone(), + votes: 0, + }); + } + } + + let mut html = String::new(); + html.push_str(&format!( + "

{}

", + current_poll.first().unwrap().title.clone() + )); + + let mut existing_choice = None; + for cookie in jar.iter() { + if cookie.name().contains("poll_") { + let id = cookie.name().replace("poll_", ""); + if let Ok(id) = id.parse::() { + if id == poll_config.id { + if let Ok(value) = cookie.value().parse::() { + existing_choice = Some(value); + } + } + } + } + } + + html.push_str("
"); + html.push_str("
"); + + html.push_str("
"); + html.push_str("
"); + let inert = if existing_choice.is_some() { "inert" } else { "" }; + html.push_str(&format!("
", inert)); + for choice in choices { + let checked = if Some(choice.id) == existing_choice { "checked" } else { "" }; + html.push_str(&format!("", choice.id, choice.id, checked)); + html.push_str(&format!( + "", + choice.id, + choice.name.clone() + )); + } + html.push_str("
"); + html.push_str(&format!("", inert)); + html.push_str(""); + html.push_str("
"); + html.push_str("
"); + + return Html(html); + } + } + } + } + } + + Html("

No poll is open right now!

".to_string()) +} + +async fn poll_frame_results(State(state): State, jar: CookieJar) -> Html { + if let Ok(data) = std::fs::read_to_string("polls.json") { + if let Ok(config) = serde_json::from_str::(&data) { + if let Some(current_poll) = config.current_poll { + let current_poll: Vec<&PollConfig> = config + .polls + .iter() + .filter(|poll| poll.id == current_poll) + .collect(); + if !current_poll.is_empty() { + if let Some(poll) = state.get_poll_votes_by_poll_id(current_poll[0].id) { + let poll_config = current_poll.first().unwrap(); + let mut choices = vec![]; + + let find_vote_choice = |id: i32| { + for choice in &poll.choices { + if choice.id == id { + return Some(choice); + } + } + + return None; + }; + + for choice in &poll_config.choices { + if let Some(vote_choice) = find_vote_choice(choice.id) { + choices.push(PollChoiceResponse { + id: choice.id, + name: choice.name.clone(), + url: choice.url.clone(), + votes: vote_choice.votes, + }); + } else { + choices.push(PollChoiceResponse { + id: choice.id, + name: choice.name.clone(), + url: choice.url.clone(), + votes: 0, + }); + } + } + + let mut html = String::new(); + html.push_str(&format!( + "

{}

", + current_poll.first().unwrap().title.clone() + )); + + html.push_str("
"); + html.push_str("
"); + + let mut existing_choice = None; + for cookie in jar.iter() { + if cookie.name().contains("poll_") { + let id = cookie.name().replace("poll_", ""); + if let Ok(id) = id.parse::() { + if id == poll_config.id { + if let Ok(value) = cookie.value().parse::() { + existing_choice = Some(value); + } + } + } + } + } + + html.push_str("
"); + html.push_str("
"); + html.push_str("
"); + for choice in choices { + let checked = if Some(choice.id) == existing_choice { "checked" } else { "" }; + + html.push_str(&format!("", choice.id, choice.id, checked)); + html.push_str(&format!( + "", + choice.id, + choice.name.clone(), + choice.votes + )); + } + html.push_str("
"); + html.push_str(""); + html.push_str("
"); + html.push_str("
"); + + return Html(html); + } + } + } + } + } + + Html("

No poll is open right now!

".to_string()) } #[tokio::main] @@ -299,17 +505,15 @@ async fn main() { .route("/votes/{slug}", post(leaderboard_submit_score)) .route("/votes/{slug}", get(view_votes)) .route("/polls/current", get(view_current_poll)) - .route("/polls/vote/{id}", post(vote_current_poll)) + .route("/polls/vote", post(vote_current_poll)) + .route("/polls/frame/vote", get(poll_frame_vote)) + .route("/polls/frame/results", get(poll_frame_results)) .with_state(initial_state.clone()); let addr = SocketAddr::from(([127, 0, 0, 1], 9183)); tracing::info!("Listening on {}", addr); - let listener = tokio::net::TcpListener::bind(&addr) - .await - .unwrap(); - axum::serve(listener, app) - .await - .unwrap(); + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); let state_clone = initial_state.clone(); state_clone.save();