add history page and submission tracking functionality
This commit is contained in:
@@ -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>
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user