feat(recording): stream + decrypt recordings in memory via waaudio:// (FR-REC-5)

This commit is contained in:
iamdoubz
2026-07-06 18:40:12 -05:00
parent 57127cfa07
commit 1634820523
+121 -18
View File
@@ -1648,36 +1648,139 @@ pub async fn delete_meeting(state: State<'_, AppState>, meeting_id: MeetingId) -
.map_err(|e| WaError::new("storage", e.to_string()))
}
/// Return a filesystem path to a playable `audio.wav` for the in-app player
/// (FR-REC-5). A plaintext recording plays in place; one sealed at rest (T8.8)
/// is decrypted to a sibling `audio.play.wav` (requires the vault unlocked).
/// Errors when the meeting has no retained recording.
/// Return the `waaudio://` URL the in-app player loads for a meeting's recording
/// (FR-REC-5). The bytes are decrypted in memory on demand by `serve_recording`
/// — nothing plaintext is ever written to disk. Prechecks that a recording
/// exists and (if sealed) the vault is unlocked, so the UI can show a clear
/// error before playback; also clears any stale plaintext `audio.play.wav` left
/// by the previous file-based player.
#[tauri::command]
pub async fn recording_playback_path(meeting_id: MeetingId) -> WaResult<String> {
let id = meeting_id.clone();
tauri::async_runtime::spawn_blocking(move || {
let wav = meeting_dir(&meeting_id).join("audio.wav");
let dir = meeting_dir(&id);
let wav = dir.join("audio.wav");
if !wav.exists() {
return Err(WaError::new(
"recording",
"no saved recording for this meeting",
));
}
let raw = std::fs::read(&wav).map_err(|e| WaError::new("recording", e.to_string()))?;
if !crate::vault::is_sealed(&raw) {
return Ok(wav.to_string_lossy().into_owned());
// Cheap sealed check — read only the magic prefix, not the whole file.
let mut head = [0u8; 8];
let sealed = std::fs::File::open(&wav)
.and_then(|mut f| {
use std::io::Read;
let n = f.read(&mut head)?;
Ok(n)
})
.map(|n| crate::vault::is_sealed(&head[..n]))
.unwrap_or(false);
if sealed && !crate::vault::is_unlocked() {
return Err(WaError::new(
"recording",
"unlock the vault to play this recording",
));
}
// ponytail: writes a plaintext copy beside the sealed original for the
// player; it lives only until the meeting is deleted, and only appears
// once the user chooses to play an encrypted recording.
let plain = crate::vault::open(&raw).map_err(|_| {
WaError::new("recording", "unlock the vault to play this recording")
})?;
let play = meeting_dir(&meeting_id).join("audio.play.wav");
std::fs::write(&play, plain).map_err(|e| WaError::new("recording", e.to_string()))?;
Ok(play.to_string_lossy().into_owned())
// Drop any plaintext temp the old file-based player left behind.
let _ = std::fs::remove_file(dir.join("audio.play.wav"));
Ok(())
})
.await
.map_err(|e| WaError::new("recording", e.to_string()))?
.map_err(|e| WaError::new("recording", e.to_string()))??;
Ok(format!("http://waaudio.localhost/{meeting_id}"))
}
/// Custom-scheme handler backing the `waaudio://` URL: reads the meeting's
/// `audio.wav`, decrypts it in memory if sealed (T8.8), and streams the PCM to
/// the `<audio>` element with byte-range support for seeking. The plaintext
/// never touches the filesystem, so playback can't undermine encryption at rest.
pub(crate) fn serve_recording(
request: &tauri::http::Request<Vec<u8>>,
) -> tauri::http::Response<Vec<u8>> {
use tauri::http::{header, Response, StatusCode};
let fail = |code: StatusCode| {
Response::builder()
.status(code)
.body(Vec::new())
.expect("static error response")
};
// Guard the id against path traversal before joining it into a path.
let id = request.uri().path().trim_start_matches('/').to_string();
if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return fail(StatusCode::BAD_REQUEST);
}
let raw = match std::fs::read(meeting_dir(&id).join("audio.wav")) {
Ok(b) => b,
Err(_) => return fail(StatusCode::NOT_FOUND),
};
let plain = match crate::vault::open(&raw) {
Ok(p) => p,
Err(_) => return fail(StatusCode::FORBIDDEN), // sealed + vault locked
};
let total = plain.len();
let base = || {
Response::builder()
.header(header::CONTENT_TYPE, "audio/wav")
.header(header::ACCEPT_RANGES, "bytes")
};
// Honour a single `Range` request so the player can seek.
if let Some(range) = request
.headers()
.get(header::RANGE)
.and_then(|v| v.to_str().ok())
{
if let Some((start, end)) = parse_byte_range(range, total) {
return base()
.status(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{total}"))
.header(header::CONTENT_LENGTH, (end - start + 1).to_string())
.body(plain[start..=end].to_vec())
.unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR));
}
}
base()
.status(StatusCode::OK)
.header(header::CONTENT_LENGTH, total.to_string())
.body(plain)
.unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR))
}
/// Parse a single `bytes=start-end` range against `total`, returning inclusive
/// clamped indices. Supports open-ended (`start-`) and suffix (`-n`) forms; only
/// the common single-range case is handled (enough for an `<audio>` element).
fn parse_byte_range(header: &str, total: usize) -> Option<(usize, usize)> {
if total == 0 {
return None;
}
let (s, e) = header.strip_prefix("bytes=")?.split_once('-')?;
if s.is_empty() {
let n: usize = e.parse().ok()?;
return Some((total.saturating_sub(n), total - 1));
}
let start: usize = s.parse().ok()?;
let end = if e.is_empty() {
total - 1
} else {
e.parse::<usize>().ok()?.min(total - 1)
};
(start <= end && start < total).then_some((start, end))
}
/// Remove any leftover plaintext `audio.play.wav` files from the previous
/// file-based player, so no decrypted audio lingers on disk (T8.8).
pub(crate) fn cleanup_playback_temp() {
let Ok(entries) = std::fs::read_dir(crate::paths::meetings_dir()) else {
return;
};
for entry in entries.flatten() {
let play = entry.path().join("audio.play.wav");
if play.exists() {
let _ = std::fs::remove_file(play);
}
}
}
#[tauri::command]