From 576199b1e98310dd419f6f964384f50d75def5f8 Mon Sep 17 00:00:00 2001 From: Mitchell M Date: Tue, 6 Oct 2026 15:36:34 -0500 Subject: [PATCH] add history page and submission tracking functionality --- src/app.rs | 3 +- src/codes/code1.rs | 1 + src/codes/history.rs | 379 +++++++++++++++++++++++++++++++++++++ src/codes/mod.rs | 9 +- src/main.rs | 5 +- src/manage.rs | 12 +- style/main.scss | 76 +++++++- successful_submissions.tsv | 2 + 8 files changed, 480 insertions(+), 7 deletions(-) create mode 100644 src/codes/history.rs create mode 100644 successful_submissions.tsv diff --git a/src/app.rs b/src/app.rs index d5efaf9..d3ab12e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,7 +5,7 @@ use leptos_router::{ ParamSegment, StaticSegment, }; -use crate::codes::{HomePage, SubmitPage}; +use crate::codes::{history::HistoryPage, HomePage, SubmitPage}; use crate::manage::ManagePage; pub fn shell(options: LeptosOptions) -> impl IntoView { @@ -45,6 +45,7 @@ pub fn App() -> impl IntoView { + = Mutex::new(()); + +/// Records a successful code submission in the persistent history file. +#[cfg(feature = "ssr")] +pub(crate) fn record_successful_submission( + ip_address: IpAddr, + server_was_live: bool, + code_id: &str, +) -> Result<(), ServerFnError> { + let _guard = SUBMISSION_HISTORY_LOCK + .lock() + .map_err(|_| ServerFnError::new("Submission history is unavailable"))?; + + persist_submission(&SuccessfulSubmission { + submitted_at: SystemTime::now(), + ip_address, + server_was_live, + code_id: code_id.to_string(), + }) +} + +/// Returns a snapshot of successful submissions persisted in the history file. +#[cfg(feature = "ssr")] +pub fn successful_submission_history() -> Result, ServerFnError> { + let _guard = SUBMISSION_HISTORY_LOCK + .lock() + .map_err(|_| ServerFnError::new("Submission history is unavailable"))?; + + let file = match fs::File::open(SUBMISSION_HISTORY_FILE) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(ServerFnError::new(format!( + "Unable to open submission history: {error}" + ))); + } + }; + + BufReader::new(file) + .lines() + .enumerate() + .map(|(index, line)| { + let line = line.map_err(|error| { + ServerFnError::new(format!( + "Unable to read submission history line {}: {error}", + index + 1 + )) + })?; + parse_submission_record(&line).map_err(|error| { + ServerFnError::new(format!( + "Invalid submission history line {}: {error}", + index + 1 + )) + }) + }) + .collect() +} + +type HistoryViewEntry = (String, String, bool, String, String); + +#[server] +async fn get_submission_history( + secret_key: String, +) -> Result, ServerFnError> { + if !crate::manage::valid_status_secret_key(&secret_key) { + return Err(ServerFnError::new("Invalid secret key")); + } + + let mut submissions = successful_submission_history()?; + submissions.sort_by(|left, right| right.submitted_at.cmp(&left.submitted_at)); + + submissions + .into_iter() + .map(|submission| { + let timestamp = + submission + .submitted_at + .duration_since(UNIX_EPOCH) + .map_err(|error| { + ServerFnError::new(format!("Invalid submission timestamp: {error}")) + })?; + let age = SystemTime::now() + .duration_since(submission.submitted_at) + .map_err(|error| { + ServerFnError::new(format!("Submission timestamp is in the future: {error}")) + })?; + Ok(( + format_timestamp(timestamp.as_secs(), timestamp.subsec_nanos()), + submission.ip_address.to_string(), + submission.server_was_live, + submission.code_id, + format_elapsed(age.as_secs()), + )) + }) + .collect() +} + +#[cfg(feature = "ssr")] +fn format_elapsed(seconds: u64) -> String { + let units = [ + (365 * 24 * 60 * 60, "year"), + (30 * 24 * 60 * 60, "month"), + (7 * 24 * 60 * 60, "week"), + (24 * 60 * 60, "day"), + (60 * 60, "hour"), + (60, "minute"), + (1, "second"), + ]; + + for (unit_seconds, unit_name) in units { + let amount = seconds / unit_seconds; + if amount > 0 { + let suffix = if amount == 1 { "" } else { "s" }; + return format!("{amount} {unit_name}{suffix} ago"); + } + } + + "Just now".to_string() +} + +#[cfg(feature = "ssr")] +fn format_timestamp(seconds: u64, nanos: u32) -> String { + let days = (seconds / 86_400) as i64; + let seconds_in_day = seconds % 86_400; + let z = days + 719_468; + let era = z / 146_097; + let day_of_era = z - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let mut year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_part = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_part + 2) / 5 + 1; + let month = month_part + if month_part < 10 { 3 } else { -9 }; + year += i64::from(month <= 2); + + format!( + "{year:04}-{month:02}-{day:02} {:02}:{:02}:{:02}.{nanos:09} UTC", + seconds_in_day / 3_600, + (seconds_in_day % 3_600) / 60, + seconds_in_day % 60 + ) +} + +#[cfg(feature = "ssr")] +fn parse_submission_record(line: &str) -> Result { + let mut fields = line.split('\t'); + let seconds = fields + .next() + .ok_or("missing timestamp seconds")? + .parse::() + .map_err(|error| format!("invalid timestamp seconds: {error}"))?; + let nanos = fields + .next() + .ok_or("missing timestamp nanoseconds")? + .parse::() + .map_err(|error| format!("invalid timestamp nanoseconds: {error}"))?; + if nanos >= 1_000_000_000 { + return Err("timestamp nanoseconds are out of range".to_string()); + } + let ip_address = fields + .next() + .ok_or("missing IP address")? + .parse::() + .map_err(|error| format!("invalid IP address: {error}"))?; + let server_was_live = match fields.next().ok_or("missing live state")? { + "true" => true, + "false" => false, + _ => return Err("invalid live state".to_string()), + }; + // Older history entries predate the code ID column and could only be code1. + let code_id = fields.next().unwrap_or("code1").to_string(); + if fields.next().is_some() + || code_id.is_empty() + || !code_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') + { + return Err("invalid code ID or unexpected extra fields".to_string()); + } + + Ok(SuccessfulSubmission { + submitted_at: UNIX_EPOCH + Duration::new(seconds, nanos), + ip_address, + server_was_live, + code_id, + }) +} + +#[cfg(feature = "ssr")] +fn persist_submission(submission: &SuccessfulSubmission) -> Result<(), ServerFnError> { + let timestamp = submission + .submitted_at + .duration_since(UNIX_EPOCH) + .map_err(|error| ServerFnError::new(format!("Invalid submission timestamp: {error}")))?; + let record = format!( + "{}\t{}\t{}\t{}\t{}\n", + timestamp.as_secs(), + timestamp.subsec_nanos(), + submission.ip_address, + submission.server_was_live, + submission.code_id + ); + + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(SUBMISSION_HISTORY_FILE) + .map_err(|error| { + ServerFnError::new(format!("Unable to open submission history: {error}")) + })?; + file.write_all(record.as_bytes()) + .and_then(|()| file.sync_data()) + .map_err(|error| { + ServerFnError::new(format!("Unable to persist submission history: {error}")) + }) +} + +#[server] +async fn clear_submission_history(secret_key: String) -> Result<(), ServerFnError> { + if !crate::manage::valid_status_secret_key(&secret_key) { + return Err(ServerFnError::new("Invalid secret key")); + } + + let _guard = SUBMISSION_HISTORY_LOCK + .lock() + .map_err(|_| ServerFnError::new("Submission history is unavailable"))?; + OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(SUBMISSION_HISTORY_FILE) + .and_then(|file| file.sync_all()) + .map_err(|error| ServerFnError::new(format!("Unable to clear submission history: {error}"))) +} + +#[component] +pub fn HistoryPage() -> impl IntoView { + let (secret_key, set_secret_key) = signal(String::new()); + let (entries, set_entries) = signal(Vec::::new()); + let (loaded, set_loaded) = signal(false); + let (loading, set_loading) = signal(false); + let (error, set_error) = signal(None::); + + let on_load = move |event: leptos::ev::SubmitEvent| { + event.prevent_default(); + let key = secret_key.get_untracked(); + set_loading.set(true); + set_error.set(None); + + leptos::task::spawn_local(async move { + match get_submission_history(key).await { + Ok(history) => { + set_entries.set(history); + set_loaded.set(true); + } + Err(error) => { + set_loaded.set(false); + set_error.set(Some(error.to_string())); + } + } + set_loading.set(false); + }); + }; + + let on_clear = move |_| { + let key = secret_key.get_untracked(); + set_loading.set(true); + set_error.set(None); + + leptos::task::spawn_local(async move { + match clear_submission_history(key).await { + Ok(()) => { + set_entries.set(Vec::new()); + set_loaded.set(true); + } + Err(error) => set_error.set(Some(error.to_string())), + } + set_loading.set(false); + }); + }; + + view! { +
+

"CODE SUBMISSION"

+

"Submission history"

+

"Enter the management key to view successful submissions."

+
+ + +
+ + +
+
+ {move || error.get().map(|message| view! { + + })} + {move || loaded.get().then(|| view! { +
+ + + + + + + + + + + + {move || entries.get().into_iter().map(|entry| view! { + + + + + + + + }).collect_view()} + +
"Submitted (UTC)""Time since submission""IP address""Code""Live"
{entry.0}{entry.4}{entry.1}{entry.3}{if entry.2 { "Yes" } else { "No" }}
+ {move || (entries.with(Vec::is_empty)).then(|| view! { +

"No successful submissions recorded."

+ })} +
+ })} + +
+ } +} diff --git a/src/codes/mod.rs b/src/codes/mod.rs index 8db2b20..f731042 100644 --- a/src/codes/mod.rs +++ b/src/codes/mod.rs @@ -8,10 +8,17 @@ use leptos_router::{ #[cfg(feature = "ssr")] mod code1; +pub mod history; + #[server] async fn submit_code(code: String) -> Result { - let live = crate::manage::server_live_state(); let message = if code == crate::codes::code1::CODE { + let live = crate::manage::server_live_state(); + let axum::extract::ConnectInfo(address): axum::extract::ConnectInfo = + leptos_axum::extract().await?; + + history::record_successful_submission(address.ip(), live, crate::codes::code1::ID)?; + crate::codes::code1::on_submit(live); if live { diff --git a/src/main.rs b/src/main.rs index c0845bb..90eb9c7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,7 +26,10 @@ async fn main() { // `axum::Server` is a re-export of `hyper::Server` log!("listening on http://{}", &addr); let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); - axum::serve(listener, app.into_make_service()) + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) .await .unwrap(); } diff --git a/src/manage.rs b/src/manage.rs index 55d91d3..a1b407c 100644 --- a/src/manage.rs +++ b/src/manage.rs @@ -15,6 +15,11 @@ pub(crate) fn server_live_state() -> bool { #[cfg(feature = "ssr")] const STATUS_SECRET_KEY: &str = "password"; +#[cfg(feature = "ssr")] +pub(crate) fn valid_status_secret_key(secret_key: &str) -> bool { + secret_key == STATUS_SECRET_KEY +} + #[server] async fn get_live_state() -> Result { Ok(server_live_state()) @@ -22,7 +27,7 @@ async fn get_live_state() -> Result { #[server] async fn set_live_state(live: bool, secret_key: String) -> Result<(), ServerFnError> { - if secret_key != STATUS_SECRET_KEY { + if !valid_status_secret_key(&secret_key) { return Err(ServerFnError::ServerError("Invalid secret key".to_string())); } @@ -52,7 +57,10 @@ pub fn ManagePage() -> impl IntoView { } })} - "Back to home" + } } diff --git a/style/main.scss b/style/main.scss index 9bc3e7b..ef73ec0 100644 --- a/style/main.scss +++ b/style/main.scss @@ -2,6 +2,7 @@ body { min-height: 100vh; margin: 0; display: grid; + grid-template-columns: minmax(0, 1fr); place-items: center; padding: 24px; box-sizing: border-box; @@ -13,10 +14,15 @@ body { } main { - width: min(100%, 480px); + width: 100%; + max-width: 1200px; + box-sizing: border-box; } .status-card { + width: min(100%, 480px); + box-sizing: border-box; + margin-inline: auto; padding: clamp(28px, 7vw, 48px); border: 1px solid rgba(148, 163, 184, 0.2); border-radius: 24px; @@ -55,6 +61,17 @@ h1 { gap: 12px; } +.history-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.history-actions .status-button { + flex: 1; +} + .key-label { color: #d3dceb; font-size: 0.9rem; @@ -111,6 +128,14 @@ h1 { background: #d05242; } +.clear-history-button { + background: #8f3030; +} + +.clear-history-button:hover:not(:disabled) { + background: #ad3939; +} + .status-button:hover:not(:disabled) { transform: translateY(-1px); } @@ -162,18 +187,65 @@ h1 { .home-link { display: inline-block; - margin-top: 24px; color: #8bbaff; font-size: 0.9rem; font-weight: 600; text-decoration: none; } +.page-nav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + margin-top: 24px; +} + .home-link:hover { color: #b3d2ff; text-decoration: underline; } +.history-card { + width: 100%; + overflow: hidden; +} + +.history-table-wrap { + margin-top: 28px; + overflow-x: auto; +} + +.history-table { + width: 100%; + border-collapse: collapse; + text-align: left; + white-space: nowrap; +} + +.history-table th, +.history-table td { + padding: 12px 14px; + border-bottom: 1px solid rgba(148, 163, 184, 0.16); +} + +.history-table th { + color: #a9b6c9; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.history-table td { + color: #e6edf7; + font-size: 0.9rem; +} + +.history-table tbody tr:last-child td { + border-bottom: 0; +} + @media (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important; diff --git a/successful_submissions.tsv b/successful_submissions.tsv new file mode 100644 index 0000000..3a64f4d --- /dev/null +++ b/successful_submissions.tsv @@ -0,0 +1,2 @@ +1791318883 152713800 127.0.0.1 false code1 +1791318908 161513000 127.0.0.1 true code1