feat: voice note submission, validation, and storage
This commit is contained in:
parent
06336d8146
commit
05022a1669
1 changed files with 159 additions and 2 deletions
161
src/web.rs
161
src/web.rs
|
|
@ -33,6 +33,8 @@ pub struct SubmitForm {
|
||||||
captcha: String,
|
captcha: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
drawing: String,
|
drawing: String,
|
||||||
|
#[serde(default)]
|
||||||
|
voice_note: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(state: Arc<AppState>) -> Router {
|
pub fn router(state: Arc<AppState>) -> Router {
|
||||||
|
|
@ -185,6 +187,34 @@ async fn submit(
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Process voice note if enabled and provided
|
||||||
|
let voice_note_bytes: Option<Vec<u8>> = if state.config.enable_voice_notes && !form.voice_note.is_empty() {
|
||||||
|
let b64 = form.voice_note
|
||||||
|
.strip_prefix("data:audio/webm;codecs=opus;base64,")
|
||||||
|
.unwrap_or("");
|
||||||
|
if b64.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let bytes = match base64::engine::general_purpose::STANDARD.decode(b64) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(_) => return Html(render_error_page(&state.config, "Invalid voice note data.")),
|
||||||
|
};
|
||||||
|
let max = state.config.max_voice_note_bytes();
|
||||||
|
if max > 0 && bytes.len() > max {
|
||||||
|
return Html(render_error_page(&state.config, &format!("Voice note is too large (max {} bytes).", max)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate WebM: magic bytes
|
||||||
|
if bytes.len() < 4 || &bytes[..4] != b"\x1a\x45\xdf\xa3" {
|
||||||
|
return Html(render_error_page(&state.config, "Invalid voice note format."));
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(bytes)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
let epoch = now.timestamp();
|
let epoch = now.timestamp();
|
||||||
let short_id = &Uuid::new_v4().to_string()[..8];
|
let short_id = &Uuid::new_v4().to_string()[..8];
|
||||||
|
|
@ -205,6 +235,20 @@ async fn submit(
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let voice_note_filename = if let Some(ref bytes) = voice_note_bytes {
|
||||||
|
let vn_name = format!("{prefix}.webm");
|
||||||
|
let vn_dir = state.config.data_dir.join("voice_notes");
|
||||||
|
std::fs::create_dir_all(&vn_dir).ok();
|
||||||
|
if let Err(e) = std::fs::write(vn_dir.join(&vn_name), bytes) {
|
||||||
|
tracing::error!("failed to write voice note: {e}");
|
||||||
|
return Html(render_error_page(&state.config, "Something went wrong. Please try again."));
|
||||||
|
}
|
||||||
|
vn_name
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
let entry = Entry {
|
let entry = Entry {
|
||||||
id: filename.trim_end_matches(".txt").to_string(),
|
id: filename.trim_end_matches(".txt").to_string(),
|
||||||
meta: EntryMeta {
|
meta: EntryMeta {
|
||||||
|
|
@ -212,7 +256,7 @@ async fn submit(
|
||||||
date,
|
date,
|
||||||
website,
|
website,
|
||||||
drawing: drawing_filename,
|
drawing: drawing_filename,
|
||||||
voice_note: String::new(),
|
voice_note: voice_note_filename,
|
||||||
status: Status::Pending,
|
status: Status::Pending,
|
||||||
},
|
},
|
||||||
body: message,
|
body: message,
|
||||||
|
|
@ -228,7 +272,7 @@ async fn submit(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify telegram task
|
// Notify telegram task
|
||||||
let _ = state.tx.send((entry, drawing_bytes, None)).await;
|
let _ = state.tx.send((entry, drawing_bytes, voice_note_bytes)).await;
|
||||||
|
|
||||||
Html(render_success_page(&state.config))
|
Html(render_success_page(&state.config))
|
||||||
}
|
}
|
||||||
|
|
@ -606,6 +650,12 @@ mod tests {
|
||||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fake_webm() -> Vec<u8> {
|
||||||
|
let mut webm = vec![0x1A, 0x45, 0xDF, 0xA3];
|
||||||
|
webm.extend_from_slice(&[0; 50]);
|
||||||
|
webm
|
||||||
|
}
|
||||||
|
|
||||||
/// Build a fake but valid PNG with the given dimensions.
|
/// Build a fake but valid PNG with the given dimensions.
|
||||||
fn fake_png(width: u32, height: u32) -> Vec<u8> {
|
fn fake_png(width: u32, height: u32) -> Vec<u8> {
|
||||||
let mut png = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
|
let mut png = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
|
||||||
|
|
@ -822,4 +872,111 @@ mod tests {
|
||||||
assert!(body.contains("Name and message are required"));
|
assert!(body.contains("Name and message are required"));
|
||||||
assert!(body.contains("back"));
|
assert!(body.contains("back"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_submit_with_voice_note() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut config = test_config(dir.path());
|
||||||
|
config.enable_voice_notes = true;
|
||||||
|
let (app, _rx) = test_app(config);
|
||||||
|
|
||||||
|
let webm = fake_webm();
|
||||||
|
let voice_data = base64::engine::general_purpose::STANDARD.encode(&webm);
|
||||||
|
let data_url = format!("data:audio/webm;codecs=opus;base64,{voice_data}");
|
||||||
|
let body = format!(
|
||||||
|
"name=alice&message=hello&voice_note={}",
|
||||||
|
urlencoding::encode(&data_url)
|
||||||
|
);
|
||||||
|
let (_, resp) = post_form(&app, &body).await;
|
||||||
|
assert!(resp.contains("pending approval"));
|
||||||
|
|
||||||
|
let entries: Vec<_> = std::fs::read_dir(dir.path().join("entries"))
|
||||||
|
.unwrap()
|
||||||
|
.collect();
|
||||||
|
assert_eq!(entries.len(), 1);
|
||||||
|
let content = std::fs::read_to_string(entries[0].as_ref().unwrap().path()).unwrap();
|
||||||
|
assert!(content.contains("voice_note = "));
|
||||||
|
|
||||||
|
let voice_notes: Vec<_> = std::fs::read_dir(dir.path().join("voice_notes"))
|
||||||
|
.unwrap()
|
||||||
|
.collect();
|
||||||
|
assert_eq!(voice_notes.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_submit_without_voice_note() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut config = test_config(dir.path());
|
||||||
|
config.enable_voice_notes = true;
|
||||||
|
let (app, _rx) = test_app(config);
|
||||||
|
let (_, resp) = post_form(&app, "name=alice&message=hello").await;
|
||||||
|
assert!(resp.contains("pending approval"));
|
||||||
|
|
||||||
|
let vn_dir = dir.path().join("voice_notes");
|
||||||
|
if vn_dir.exists() {
|
||||||
|
let count = std::fs::read_dir(&vn_dir).unwrap().count();
|
||||||
|
assert_eq!(count, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_submit_voice_note_too_large() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut config = test_config(dir.path());
|
||||||
|
config.enable_voice_notes = true;
|
||||||
|
config.voice_note_max_duration = 1;
|
||||||
|
let (app, _rx) = test_app(config);
|
||||||
|
|
||||||
|
let mut webm = vec![0x1A, 0x45, 0xDF, 0xA3];
|
||||||
|
webm.extend_from_slice(&[0; 20_000]);
|
||||||
|
let voice_data = base64::engine::general_purpose::STANDARD.encode(&webm);
|
||||||
|
let data_url = format!("data:audio/webm;codecs=opus;base64,{voice_data}");
|
||||||
|
let body = format!(
|
||||||
|
"name=alice&message=hello&voice_note={}",
|
||||||
|
urlencoding::encode(&data_url)
|
||||||
|
);
|
||||||
|
let (_, resp) = post_form(&app, &body).await;
|
||||||
|
assert!(resp.contains("too large"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_submit_voice_note_rejects_non_webm() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut config = test_config(dir.path());
|
||||||
|
config.enable_voice_notes = true;
|
||||||
|
let (app, _rx) = test_app(config);
|
||||||
|
|
||||||
|
let voice_data = base64::engine::general_purpose::STANDARD.encode(b"not a webm file");
|
||||||
|
let data_url = format!("data:audio/webm;codecs=opus;base64,{voice_data}");
|
||||||
|
let body = format!(
|
||||||
|
"name=alice&message=hello&voice_note={}",
|
||||||
|
urlencoding::encode(&data_url)
|
||||||
|
);
|
||||||
|
let (_, resp) = post_form(&app, &body).await;
|
||||||
|
assert!(resp.contains("Invalid voice note"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_submit_voice_note_ignored_when_disabled() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut config = test_config(dir.path());
|
||||||
|
config.enable_voice_notes = false;
|
||||||
|
let (app, _rx) = test_app(config);
|
||||||
|
|
||||||
|
let webm = fake_webm();
|
||||||
|
let voice_data = base64::engine::general_purpose::STANDARD.encode(&webm);
|
||||||
|
let data_url = format!("data:audio/webm;codecs=opus;base64,{voice_data}");
|
||||||
|
let body = format!(
|
||||||
|
"name=alice&message=hello&voice_note={}",
|
||||||
|
urlencoding::encode(&data_url)
|
||||||
|
);
|
||||||
|
let (_, resp) = post_form(&app, &body).await;
|
||||||
|
assert!(resp.contains("pending approval"));
|
||||||
|
|
||||||
|
let entries: Vec<_> = std::fs::read_dir(dir.path().join("entries"))
|
||||||
|
.unwrap()
|
||||||
|
.collect();
|
||||||
|
let content = std::fs::read_to_string(entries[0].as_ref().unwrap().path()).unwrap();
|
||||||
|
assert!(content.contains("voice_note = \"\""));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue