refactor: forget epoch and going down to a 4char hex string

This commit is contained in:
Lewis Wynne 2026-04-10 19:38:46 +01:00
parent a7c74241a0
commit b3f5f403aa
7 changed files with 123 additions and 69 deletions

84
Cargo.lock generated
View file

@ -443,6 +443,18 @@ dependencies = [
"slab",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.2"
@ -451,7 +463,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"r-efi 6.0.0",
"wasip2",
"wasip3",
]
@ -465,6 +477,7 @@ dependencies = [
"chrono",
"dotenvy",
"http-body-util",
"rand",
"serde",
"teloxide",
"tempfile",
@ -474,7 +487,6 @@ dependencies = [
"tracing",
"tracing-subscriber",
"urlencoding",
"uuid",
]
[[package]]
@ -1072,6 +1084,15 @@ dependencies = [
"zerovec",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
@ -1124,12 +1145,47 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rc-box"
version = "1.3.0"
@ -1588,7 +1644,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@ -1876,7 +1932,7 @@ version = "1.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
dependencies = [
"getrandom",
"getrandom 0.4.2",
"js-sys",
"wasm-bindgen",
]
@ -2387,6 +2443,26 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "zerofrom"
version = "0.1.7"

View file

@ -17,7 +17,7 @@ teloxide = { version = "0.13", features = ["macros"], optional = true }
serde = { version = "1", features = ["derive"] }
toml = "0.8"
dotenvy = "0.15"
uuid = { version = "1", features = ["v4"] }
rand = "0.9"
chrono = "0.4"
base64 = "0.22"
tracing = "0.1"

View file

@ -50,12 +50,6 @@ impl Entry {
})
}
#[cfg(any(feature = "telegram", test))]
/// Return the short ID (UUID portion after the underscore).
pub fn short_id(&self) -> &str {
self.id.split('_').last().unwrap_or(&self.id)
}
/// Serialize entry back to file format.
pub fn to_file_contents(&self) -> String {
let frontmatter = toml::to_string_pretty(&self.meta).unwrap();
@ -106,20 +100,11 @@ pub fn read_approved(dir: &Path) -> Vec<Entry> {
}
#[cfg(any(feature = "telegram", test))]
/// Find a single entry by short ID (the UUID portion after the underscore).
pub fn find_entry(dir: &Path, short_id: &str) -> Result<Entry, String> {
let read_dir = std::fs::read_dir(dir).map_err(|e| e.to_string())?;
for item in read_dir {
let Ok(item) = item else { continue };
let path = item.path();
let fname = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
if fname.contains(short_id) && fname.ends_with(".txt") {
let contents = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
let id = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
return Entry::parse(&id, &contents);
}
}
Err("Not found.".into())
/// Find a single entry by ID.
pub fn find_entry(dir: &Path, id: &str) -> Result<Entry, String> {
let path = dir.join(format!("{id}.txt"));
let contents = std::fs::read_to_string(&path).map_err(|_| "Not found.".to_string())?;
Entry::parse(id, &contents)
}
#[cfg(any(feature = "telegram", test))]
@ -334,9 +319,9 @@ Hello!"#;
fn test_find_entry() {
let dir = tempfile::tempdir().unwrap();
let contents = "+++\nname = \"alice\"\ndate = \"2026-04-10\"\nstatus = \"pending\"\n+++\nhello";
std::fs::write(dir.path().join("1744300800_abcd1234.txt"), contents).unwrap();
std::fs::write(dir.path().join("ab12.txt"), contents).unwrap();
let entry = find_entry(dir.path(), "abcd1234").unwrap();
let entry = find_entry(dir.path(), "ab12").unwrap();
assert_eq!(entry.meta.name, "alice");
assert!(find_entry(dir.path(), "nonexistent").is_err());
@ -352,16 +337,16 @@ Hello!"#;
std::fs::create_dir_all(&drawings_dir).unwrap();
std::fs::create_dir_all(&vn_dir).unwrap();
let contents = "+++\nname = \"alice\"\ndate = \"2026-04-10\"\nstatus = \"denied\"\ndrawing = \"1744300800_abcd1234.png\"\nvoice_note = \"1744300800_abcd1234.webm\"\n+++\nhello";
std::fs::write(entries_dir.join("1744300800_abcd1234.txt"), contents).unwrap();
std::fs::write(drawings_dir.join("1744300800_abcd1234.png"), b"fake png").unwrap();
std::fs::write(vn_dir.join("1744300800_abcd1234.webm"), b"fake webm").unwrap();
let contents = "+++\nname = \"alice\"\ndate = \"2026-04-10\"\nstatus = \"denied\"\ndrawing = \"ab12.png\"\nvoice_note = \"ab12.webm\"\n+++\nhello";
std::fs::write(entries_dir.join("ab12.txt"), contents).unwrap();
std::fs::write(drawings_dir.join("ab12.png"), b"fake png").unwrap();
std::fs::write(vn_dir.join("ab12.webm"), b"fake webm").unwrap();
let name = delete_entry(dir.path(), "abcd1234").unwrap();
let name = delete_entry(dir.path(), "ab12").unwrap();
assert_eq!(name, "alice");
assert!(!entries_dir.join("1744300800_abcd1234.txt").exists());
assert!(!drawings_dir.join("1744300800_abcd1234.png").exists());
assert!(!vn_dir.join("1744300800_abcd1234.webm").exists());
assert!(!entries_dir.join("ab12.txt").exists());
assert!(!drawings_dir.join("ab12.png").exists());
assert!(!vn_dir.join("ab12.webm").exists());
}
#[test]
@ -371,17 +356,4 @@ Hello!"#;
assert!(delete_entry(dir.path(), "nonexistent").is_err());
}
#[test]
fn test_short_id() {
let contents = "+++\nname = \"a\"\ndate = \"2026-04-10\"\nstatus = \"pending\"\n+++\nhi";
let entry = Entry::parse("1744300800_abcd1234", contents).unwrap();
assert_eq!(entry.short_id(), "abcd1234");
}
#[test]
fn test_short_id_no_underscore() {
let contents = "+++\nname = \"a\"\ndate = \"2026-04-10\"\nstatus = \"pending\"\n+++\nhi";
let entry = Entry::parse("plainid", contents).unwrap();
assert_eq!(entry.short_id(), "plainid");
}
}

View file

@ -257,7 +257,10 @@ fn escape_html(s: &str) -> String {
fn render_entries(entries: &[Entry], config: &Config) -> String {
let mut html = String::new();
for entry in entries {
for (i, entry) in entries.iter().enumerate() {
if i > 0 {
html.push_str("<hr class=\"entry-separator\">");
}
html.push_str(&render_entry(entry, config));
}
html
@ -304,7 +307,8 @@ fn render_entry(entry: &Entry, config: &Config) -> String {
String::new()
};
format!(
"<article class=\"entry\">{header}{drawing_html}{voice_note_html}<div class=\"entry-body\">{body}</div></article>"
"<article class=\"entry\" id=\"{id}\">{header}{drawing_html}{voice_note_html}<div class=\"entry-body\">{body}</div></article>",
id = escape_html(&entry.id)
)
}
@ -397,7 +401,7 @@ mod tests {
assert!(html.contains("entry-header"));
assert!(html.contains("entry-name"));
assert!(html.contains("entry-body"));
assert!(html.contains("<article class=\"entry\">"));
assert!(html.contains("<article class=\"entry\" id=\"test\">"));
}
#[test]

View file

@ -15,7 +15,7 @@ fn format_entry_list(entries: &[Entry], status_label: &str) -> String {
let ellipsis = if entry.body.chars().count() > 30 { "..." } else { "" };
lines.push(format!(
"- {} ({}) \"{}{}\"\n /view_{}",
entry.meta.name, entry.meta.date, preview, ellipsis, entry.short_id()
entry.meta.name, entry.meta.date, preview, ellipsis, entry.id
));
}
lines.join("\n")
@ -23,10 +23,9 @@ fn format_entry_list(entries: &[Entry], status_label: &str) -> String {
/// Send a notification to Telegram about a new entry.
async fn notify(bot: &Bot, chat_id: ChatId, entry: &Entry) {
let short_id = entry.short_id();
let text = format!(
"New guestbook entry:\n\nName: {}\nWebsite: {}\n\n{}\n\n/allow_{}\n/deny_{}",
entry.meta.name, entry.meta.website, entry.body, short_id, short_id
entry.meta.name, entry.meta.website, entry.body, entry.id, entry.id
);
if let Err(e) = bot.send_message(chat_id, &text).await {
tracing::error!("failed to send telegram message: {e}");
@ -109,11 +108,10 @@ pub async fn bot_task(bot: Bot, chat_id: ChatId, data_dir: PathBuf) {
} else if let Some(id) = text.strip_prefix("/view_") {
match entries::find_entry(&entries_dir, id) {
Ok(entry) => {
let short_id = entry.short_id();
let text = format!(
"Entry ({:?}):\n\nName: {}\nWebsite: {}\nDate: {}\n\n{}\n\n/allow_{}\n/deny_{}",
entry.meta.status, entry.meta.name, entry.meta.website,
entry.meta.date, entry.body, short_id, short_id
entry.meta.date, entry.body, entry.id, entry.id
);
bot.send_message(msg.chat.id, &text).await?;

View file

@ -11,7 +11,7 @@ use axum::{
use base64::Engine;
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;
use rand::Rng;
use crate::config::Config;
use crate::entries::{self, Entry, EntryMeta, Status};
@ -49,6 +49,17 @@ pub fn router(state: Arc<AppState>) -> Router {
.with_state(state)
}
fn generate_id(entries_dir: &std::path::Path) -> String {
const CHARS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
let mut rng = rand::rng();
loop {
let id: String = (0..4).map(|_| CHARS[rng.random_range(0..36)] as char).collect();
if !entries_dir.join(format!("{id}.txt")).exists() {
return id;
}
}
}
async fn security_headers(req: axum::extract::Request, next: Next) -> Response {
let is_static = req.uri().path().starts_with("/drawings/")
|| req.uri().path().starts_with("/voice_notes/");
@ -260,15 +271,13 @@ async fn submit(
};
let now = chrono::Utc::now();
let epoch = now.timestamp();
let short_id = &Uuid::new_v4().to_string()[..8];
let prefix = format!("{epoch}_{short_id}");
let date = now.format("%Y-%m-%dT%H:%M:%S").to_string();
let filename = format!("{prefix}.txt");
let id = generate_id(&state.config.data_dir.join("entries"));
let filename = format!("{id}.txt");
// Save drawing with the same prefix as the entry
// Save drawing with the same ID as the entry
let drawing_filename = if let Some(ref bytes) = drawing_bytes {
let drawing_name = format!("{prefix}.png");
let drawing_name = format!("{id}.png");
let drawings_dir = state.config.data_dir.join("drawings");
if let Err(e) = std::fs::create_dir_all(&drawings_dir) {
tracing::error!("failed to create drawings directory: {e}");
@ -284,7 +293,7 @@ async fn submit(
};
let voice_note_filename = if let Some(ref bytes) = voice_note_bytes {
let vn_name = format!("{prefix}.webm");
let vn_name = format!("{id}.webm");
let vn_dir = state.config.data_dir.join("voice_notes");
if let Err(e) = std::fs::create_dir_all(&vn_dir) {
tracing::error!("failed to create voice notes directory: {e}");

View file

@ -108,11 +108,6 @@ audio {
/* Entries */
.entry {
margin: 0.5em 0;
padding-bottom: 0.5em;
border-bottom: 1px solid #ccc;
}
.entry:last-child {
border-bottom: none;
}
.entry-header {
margin-bottom: 0.2em;