add history page and submission tracking functionality

This commit is contained in:
2026-10-06 15:36:34 -05:00
parent acf23d5430
commit 576199b1e9
8 changed files with 480 additions and 7 deletions
+2 -1
View File
@@ -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 {
<Routes fallback=|| "Page not found.".into_view()>
<Route path=StaticSegment("") view=HomePage/>
<Route path=StaticSegment("manage") view=ManagePage/>
<Route path=StaticSegment("history") view=HistoryPage/>
<Route
path=(StaticSegment("submit"), ParamSegment("code"))
view=SubmitPage
+1
View File
@@ -1,3 +1,4 @@
pub const ID: &str = "code1";
pub const CODE: &str = "Qj4eAn";
pub const LIVE_MESSAGE: &str = "Welcome! Your code has been accepted.";
pub const NOT_LIVE_MESSAGE: &str = "Thanks for submitting your code. Please try again later.";
+379
View File
@@ -0,0 +1,379 @@
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<Vec<SuccessfulSubmission>, 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<Vec<HistoryViewEntry>, 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<SuccessfulSubmission, String> {
let mut fields = line.split('\t');
let seconds = fields
.next()
.ok_or("missing timestamp seconds")?
.parse::<u64>()
.map_err(|error| format!("invalid timestamp seconds: {error}"))?;
let nanos = fields
.next()
.ok_or("missing timestamp nanoseconds")?
.parse::<u32>()
.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::<IpAddr>()
.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::<HistoryViewEntry>::new());
let (loaded, set_loaded) = signal(false);
let (loading, set_loading) = signal(false);
let (error, set_error) = signal(None::<String>);
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! {
<section class="status-card history-card">
<p class="eyebrow">"CODE SUBMISSION"</p>
<h1>"Submission history"</h1>
<p class="intro">"Enter the management key to view successful submissions."</p>
<form class="submit-form" on:submit=on_load>
<label class="key-label" for="history-secret-key">"Management key"</label>
<input
id="history-secret-key"
class="key-input"
type="password"
autocomplete="current-password"
prop:value=secret_key
on:input=move |event| set_secret_key.set(event_target_value(&event))
/>
<div class="history-actions">
<button
class="status-button clear-history-button"
type="button"
prop:disabled=loading
on:click=on_clear
>
{move || if loading.get() { "Please wait..." } else { "Clear history" }}
</button>
<button
class="status-button go-live"
type="submit"
prop:disabled=loading
>
{move || if loading.get() { "Loading..." } else { "View history" }}
</button>
</div>
</form>
{move || error.get().map(|message| view! {
<p class="error-message" role="alert">{message}</p>
})}
{move || loaded.get().then(|| view! {
<div class="history-table-wrap">
<table class="history-table">
<thead>
<tr>
<th>"Submitted (UTC)"</th>
<th>"Time since submission"</th>
<th>"IP address"</th>
<th>"Code"</th>
<th>"Live"</th>
</tr>
</thead>
<tbody>
{move || entries.get().into_iter().map(|entry| view! {
<tr>
<td>{entry.0}</td>
<td>{entry.4}</td>
<td>{entry.1}</td>
<td>{entry.3}</td>
<td>{if entry.2 { "Yes" } else { "No" }}</td>
</tr>
}).collect_view()}
</tbody>
</table>
{move || (entries.with(Vec::is_empty)).then(|| view! {
<p class="loading">"No successful submissions recorded."</p>
})}
</div>
})}
<nav class="page-nav">
<A href="/" attr:class="home-link">"Back to home"</A>
<A href="/manage" attr:class="home-link">"Manage server status"</A>
</nav>
</section>
}
}
+8 -1
View File
@@ -8,10 +8,17 @@ use leptos_router::{
#[cfg(feature = "ssr")]
mod code1;
pub mod history;
#[server]
async fn submit_code(code: String) -> Result<String, ServerFnError> {
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<std::net::SocketAddr> =
leptos_axum::extract().await?;
history::record_successful_submission(address.ip(), live, crate::codes::code1::ID)?;
crate::codes::code1::on_submit(live);
if live {
+4 -1
View File
@@ -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::<std::net::SocketAddr>(),
)
.await
.unwrap();
}
+10 -2
View File
@@ -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<bool, ServerFnError> {
Ok(server_live_state())
@@ -22,7 +27,7 @@ async fn get_live_state() -> Result<bool, ServerFnError> {
#[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 {
}
})}
</Suspense>
<A href="/" attr:class="home-link">"Back to home"</A>
<nav class="page-nav">
<A href="/" attr:class="home-link">"Back to home"</A>
<A href="/history" attr:class="home-link">"View submission history"</A>
</nav>
</section>
}
}