feat: atomic writes for entries, and better collision security
This commit is contained in:
parent
ae3bd23e4b
commit
2fc2a2c06d
2 changed files with 75 additions and 21 deletions
|
|
@ -1,6 +1,32 @@
|
||||||
|
use rand::Rng;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::io::Write;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub fn write_atomic(path: &Path, contents: &[u8]) -> std::io::Result<()> {
|
||||||
|
let parent = path.parent().ok_or_else(|| {
|
||||||
|
std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent")
|
||||||
|
})?;
|
||||||
|
let filename = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "bad filename"))?;
|
||||||
|
let suffix: u32 = rand::rng().random();
|
||||||
|
let tmp = parent.join(format!(".{filename}.{suffix:08x}.tmp"));
|
||||||
|
let mut f = std::fs::File::create(&tmp)?;
|
||||||
|
f.write_all(contents)?;
|
||||||
|
f.sync_all()?;
|
||||||
|
drop(f);
|
||||||
|
if let Err(e) = std::fs::rename(&tmp, path) {
|
||||||
|
let _ = std::fs::remove_file(&tmp);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
if let Ok(dir) = std::fs::File::open(parent) {
|
||||||
|
let _ = dir.sync_all();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum Status {
|
pub enum Status {
|
||||||
|
|
@ -149,17 +175,16 @@ pub fn append_reply(dir: &Path, id: &str, reply: &str) -> Result<String, String>
|
||||||
entry.body.push_str("\n\n");
|
entry.body.push_str("\n\n");
|
||||||
entry.body.push_str("ed);
|
entry.body.push_str("ed);
|
||||||
let path = dir.join(format!("{}.txt", entry.id));
|
let path = dir.join(format!("{}.txt", entry.id));
|
||||||
std::fs::write(&path, entry.to_file_contents()).map_err(|e| e.to_string())?;
|
write_atomic(&path, entry.to_file_contents().as_bytes()).map_err(|e| e.to_string())?;
|
||||||
Ok(entry.meta.name.clone())
|
Ok(entry.meta.name.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(feature = "telegram", test))]
|
#[cfg(any(feature = "telegram", test))]
|
||||||
/// Find an entry file by ID and update its status.
|
|
||||||
pub fn set_status(dir: &Path, short_id: &str, status: Status) -> Result<String, String> {
|
pub fn set_status(dir: &Path, short_id: &str, status: Status) -> Result<String, String> {
|
||||||
let mut entry = find_entry(dir, short_id)?;
|
let mut entry = find_entry(dir, short_id)?;
|
||||||
entry.meta.status = status;
|
entry.meta.status = status;
|
||||||
let path = dir.join(format!("{}.txt", entry.id));
|
let path = dir.join(format!("{}.txt", entry.id));
|
||||||
std::fs::write(&path, entry.to_file_contents()).map_err(|e| e.to_string())?;
|
write_atomic(&path, entry.to_file_contents().as_bytes()).map_err(|e| e.to_string())?;
|
||||||
Ok(entry.meta.name.clone())
|
Ok(entry.meta.name.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
65
src/web.rs
65
src/web.rs
|
|
@ -49,13 +49,20 @@ pub fn router(state: Arc<AppState>) -> Router {
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_id(entries_dir: &std::path::Path) -> String {
|
fn generate_id(entries_dir: &std::path::Path) -> std::io::Result<String> {
|
||||||
const CHARS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
|
const CHARS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
|
||||||
let mut rng = rand::rng();
|
let mut rng = rand::rng();
|
||||||
loop {
|
loop {
|
||||||
let id: String = (0..4).map(|_| CHARS[rng.random_range(0..36)] as char).collect();
|
let id: String = (0..4).map(|_| CHARS[rng.random_range(0..36)] as char).collect();
|
||||||
if !entries_dir.join(format!("{id}.txt")).exists() {
|
let path = entries_dir.join(format!("{id}.txt"));
|
||||||
return id;
|
match std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(&path)
|
||||||
|
{
|
||||||
|
Ok(_) => return Ok(id),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||||
|
Err(e) => return Err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -272,19 +279,33 @@ async fn submit(
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
let date = now.format("%Y-%m-%dT%H:%M:%S").to_string();
|
let date = now.format("%Y-%m-%dT%H:%M:%S").to_string();
|
||||||
let id = generate_id(&state.config.data_dir.join("entries"));
|
|
||||||
let filename = format!("{id}.txt");
|
|
||||||
|
|
||||||
// Save drawing with the same ID as the entry
|
let entries_dir = state.config.data_dir.join("entries");
|
||||||
|
if let Err(e) = std::fs::create_dir_all(&entries_dir) {
|
||||||
|
tracing::error!("failed to create entries directory: {e}");
|
||||||
|
return Html(render_error_page(&state.config, "Something went wrong. Please try again."));
|
||||||
|
}
|
||||||
|
let id = match generate_id(&entries_dir) {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("failed to reserve entry id: {e}");
|
||||||
|
return Html(render_error_page(&state.config, "Something went wrong. Please try again."));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let filename = format!("{id}.txt");
|
||||||
|
let path = entries_dir.join(&filename);
|
||||||
|
|
||||||
let drawing_filename = if let Some(ref bytes) = drawing_bytes {
|
let drawing_filename = if let Some(ref bytes) = drawing_bytes {
|
||||||
let drawing_name = format!("{id}.png");
|
let drawing_name = format!("{id}.png");
|
||||||
let drawings_dir = state.config.data_dir.join("drawings");
|
let drawings_dir = state.config.data_dir.join("drawings");
|
||||||
if let Err(e) = std::fs::create_dir_all(&drawings_dir) {
|
if let Err(e) = std::fs::create_dir_all(&drawings_dir) {
|
||||||
tracing::error!("failed to create drawings directory: {e}");
|
tracing::error!("failed to create drawings directory: {e}");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
return Html(render_error_page(&state.config, "Something went wrong. Please try again."));
|
return Html(render_error_page(&state.config, "Something went wrong. Please try again."));
|
||||||
}
|
}
|
||||||
if let Err(e) = std::fs::write(drawings_dir.join(&drawing_name), bytes) {
|
if let Err(e) = entries::write_atomic(&drawings_dir.join(&drawing_name), bytes) {
|
||||||
tracing::error!("failed to write drawing: {e}");
|
tracing::error!("failed to write drawing: {e}");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
return Html(render_error_page(&state.config, "Something went wrong. Please try again."));
|
return Html(render_error_page(&state.config, "Something went wrong. Please try again."));
|
||||||
}
|
}
|
||||||
drawing_name
|
drawing_name
|
||||||
|
|
@ -297,10 +318,18 @@ async fn submit(
|
||||||
let vn_dir = state.config.data_dir.join("voice_notes");
|
let vn_dir = state.config.data_dir.join("voice_notes");
|
||||||
if let Err(e) = std::fs::create_dir_all(&vn_dir) {
|
if let Err(e) = std::fs::create_dir_all(&vn_dir) {
|
||||||
tracing::error!("failed to create voice notes directory: {e}");
|
tracing::error!("failed to create voice notes directory: {e}");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
if !drawing_filename.is_empty() {
|
||||||
|
let _ = std::fs::remove_file(state.config.data_dir.join("drawings").join(&drawing_filename));
|
||||||
|
}
|
||||||
return Html(render_error_page(&state.config, "Something went wrong. Please try again."));
|
return Html(render_error_page(&state.config, "Something went wrong. Please try again."));
|
||||||
}
|
}
|
||||||
if let Err(e) = std::fs::write(vn_dir.join(&vn_name), bytes) {
|
if let Err(e) = entries::write_atomic(&vn_dir.join(&vn_name), bytes) {
|
||||||
tracing::error!("failed to write voice note: {e}");
|
tracing::error!("failed to write voice note: {e}");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
if !drawing_filename.is_empty() {
|
||||||
|
let _ = std::fs::remove_file(state.config.data_dir.join("drawings").join(&drawing_filename));
|
||||||
|
}
|
||||||
return Html(render_error_page(&state.config, "Something went wrong. Please try again."));
|
return Html(render_error_page(&state.config, "Something went wrong. Please try again."));
|
||||||
}
|
}
|
||||||
vn_name
|
vn_name
|
||||||
|
|
@ -314,22 +343,22 @@ async fn submit(
|
||||||
name,
|
name,
|
||||||
date,
|
date,
|
||||||
website,
|
website,
|
||||||
drawing: drawing_filename,
|
drawing: drawing_filename.clone(),
|
||||||
voice_note: voice_note_filename,
|
voice_note: voice_note_filename.clone(),
|
||||||
status: Status::Pending,
|
status: Status::Pending,
|
||||||
},
|
},
|
||||||
body: message,
|
body: message,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Write to disk
|
if let Err(e) = entries::write_atomic(&path, entry.to_file_contents().as_bytes()) {
|
||||||
let entries_dir = state.config.data_dir.join("entries");
|
|
||||||
if let Err(e) = std::fs::create_dir_all(&entries_dir) {
|
|
||||||
tracing::error!("failed to create entries directory: {e}");
|
|
||||||
return Html(render_error_page(&state.config, "Something went wrong. Please try again."));
|
|
||||||
}
|
|
||||||
let path = entries_dir.join(&filename);
|
|
||||||
if let Err(e) = std::fs::write(&path, entry.to_file_contents()) {
|
|
||||||
tracing::error!("failed to write entry: {e}");
|
tracing::error!("failed to write entry: {e}");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
if !drawing_filename.is_empty() {
|
||||||
|
let _ = std::fs::remove_file(state.config.data_dir.join("drawings").join(&drawing_filename));
|
||||||
|
}
|
||||||
|
if !voice_note_filename.is_empty() {
|
||||||
|
let _ = std::fs::remove_file(state.config.data_dir.join("voice_notes").join(&voice_note_filename));
|
||||||
|
}
|
||||||
return Html(render_error_page(&state.config, "Something went wrong. Please try again."));
|
return Html(render_error_page(&state.config, "Something went wrong. Please try again."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue