feat(recording): stream + decrypt recordings in memory via waaudio:// (FR-REC-5)
This commit is contained in:
+121
-18
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user