use leptos::prelude::*; use leptos_router::components::A; #[cfg(feature = "ssr")] use std::{ fs::{self, OpenOptions}, io::{BufRead, BufReader, Write}, net::IpAddr, sync::Mutex, time::{Duration, SystemTime, UNIX_EPOCH}, }; #[cfg(feature = "ssr")] pub const SUBMISSION_HISTORY_FILE: &str = "successful_submissions.tsv"; #[cfg(feature = "ssr")] #[derive(Clone, Debug)] pub struct SuccessfulSubmission { pub submitted_at: SystemTime, pub ip_address: IpAddr, pub server_was_live: bool, pub code_id: String, } #[cfg(feature = "ssr")] static SUBMISSION_HISTORY_LOCK: Mutex<()> = 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::secrets::validate_dev_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::secrets::validate_dev_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."

})}
})}
} }