feat(recording): stream + decrypt recordings in memory via waaudio:// (FR-REC-5)
This commit is contained in:
1 parent
57127cfa07
commit
1634820523
1 file changed
+121
-18
+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()))
|
.map_err(|e| WaError::new("storage", e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a filesystem path to a playable `audio.wav` for the in-app player
|
/// Return the `waaudio://` URL the in-app player loads for a meeting's recording
|
||||||
/// (FR-REC-5). A plaintext recording plays in place; one sealed at rest (T8.8)
|
/// (FR-REC-5). The bytes are decrypted in memory on demand by `serve_recording`
|
||||||
/// is decrypted to a sibling `audio.play.wav` (requires the vault unlocked).
|
/// — nothing plaintext is ever written to disk. Prechecks that a recording
|
||||||
/// Errors when the meeting has no retained 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]
|
#[tauri::command]
|
||||||
pub async fn recording_playback_path(meeting_id: MeetingId) -> WaResult<String> {
|
pub async fn recording_playback_path(meeting_id: MeetingId) -> WaResult<String> {
|
||||||
|
let id = meeting_id.clone();
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
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() {
|
if !wav.exists() {
|
||||||
return Err(WaError::new(
|
return Err(WaError::new(
|
||||||
"recording",
|
"recording",
|
||||||
"no saved recording for this meeting",
|
"no saved recording for this meeting",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let raw = std::fs::read(&wav).map_err(|e| WaError::new("recording", e.to_string()))?;
|
// Cheap sealed check — read only the magic prefix, not the whole file.
|
||||||
if !crate::vault::is_sealed(&raw) {
|
let mut head = [0u8; 8];
|
||||||
return Ok(wav.to_string_lossy().into_owned());
|
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
|
// Drop any plaintext temp the old file-based player left behind.
|
||||||
// player; it lives only until the meeting is deleted, and only appears
|
let _ = std::fs::remove_file(dir.join("audio.play.wav"));
|
||||||
// once the user chooses to play an encrypted recording.
|
Ok(())
|
||||||
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())
|
|
||||||
})
|
})
|
||||||
.await
|
.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]
|
#[tauri::command]
|
||||||
|
|||||||
Reference in new issue
Block a user