feat(sync): target management + test-connection commands; wire allowlist (T9.3/9.8)

This commit is contained in:
iamdoubz
2026-07-02 20:17:03 -05:00
parent 10af546d7f
commit e6911a6bff
+222 -21
View File
@@ -18,7 +18,7 @@ use crate::paths::{
diarization_embedding_model_file, diarization_segmentation_model_file, meeting_dir,
settings_path, wa_root, whisper_model_file,
};
use crate::storage::{FinalizeMeeting, Meeting, NewMeeting, SummaryFile};
use crate::storage::{FinalizeMeeting, Meeting, NewMeeting, SummaryFile, SyncTargetRow};
use crate::transcription::{
models as model_catalog, run_streaming_worker, Transcriber, WhisperTranscriber,
};
@@ -1750,27 +1750,156 @@ pub async fn attach_meeting_to_event(
// ---- Sync / upload (Phase 9, ADR-0010) ----
#[tauri::command]
pub async fn list_sync_targets() -> WaResult<Vec<SyncTargetInfo>> {
// Never returns secrets (FR-SYNC-6).
Err(not_implemented("list_sync_targets"))
/// Config payload for add/update. Snake→camel so the TS side stays idiomatic;
/// the `secret` (app password) is write-only — it goes to the OS store, never
/// back to the UI (FR-SYNC-6).
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SyncTargetConfigInput {
pub id: Option<String>,
pub name: Option<String>,
pub kind: Option<String>, // webdav (default) | onedrive | dropbox | box
pub provider_hint: Option<String>,
pub base_url: Option<String>,
pub remote_base_path: Option<String>,
pub username: Option<String>,
pub secret: Option<String>,
pub enabled: Option<bool>,
pub upload_transcript: Option<bool>,
pub upload_notes: Option<bool>,
pub upload_summary: Option<bool>,
pub upload_recording: Option<bool>,
pub trigger_on_finalize: Option<bool>,
pub allow_plaintext_lan: Option<bool>,
}
#[tauri::command]
pub async fn add_sync_target(_config: serde_json::Value) -> WaResult<SyncTargetInfo> {
// `config` includes a `secret` stored to the OS credential store, not the DB.
Err(not_implemented("add_sync_target"))
pub async fn list_sync_targets(state: State<'_, AppState>) -> WaResult<Vec<SyncTargetInfo>> {
// Never returns secrets (FR-SYNC-6) — row_to_info drops credential_ref.
let rows = state
.store
.list_sync_targets()
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
Ok(rows.iter().map(crate::sync::row_to_info).collect())
}
#[tauri::command]
pub async fn update_sync_target(_config: serde_json::Value) -> WaResult<SyncTargetInfo> {
// `config` carries an `id` and optional fields; an optional `secret` updates the credential store.
Err(not_implemented("update_sync_target"))
pub async fn add_sync_target(
state: State<'_, AppState>,
config: SyncTargetConfigInput,
) -> WaResult<SyncTargetInfo> {
let id = uuid::Uuid::new_v4().to_string();
let credential_ref = format!("wa-sync-{id}");
// Secret goes to the OS credential store keyed by credential_ref, never the DB.
if let Some(secret) = config.secret.as_deref().filter(|s| !s.is_empty()) {
crate::sync::credentials::set(&credential_ref, secret)
.map_err(|e| WaError::new("sync", e.to_string()))?;
}
let row = SyncTargetRow {
id,
name: config.name.unwrap_or_else(|| "WebDAV target".to_string()),
kind: config.kind.unwrap_or_else(|| "webdav".to_string()),
provider_hint: config.provider_hint,
base_url: config.base_url,
remote_base_path: config
.remote_base_path
.unwrap_or_else(|| "/WhispAssist".to_string()),
username: config.username,
credential_ref,
enabled: config.enabled.unwrap_or(false),
upload_transcript: config.upload_transcript.unwrap_or(true),
upload_notes: config.upload_notes.unwrap_or(true),
upload_summary: config.upload_summary.unwrap_or(true),
upload_recording: config.upload_recording.unwrap_or(false),
trigger_on_finalize: config.trigger_on_finalize.unwrap_or(true),
allow_plaintext_lan: config.allow_plaintext_lan.unwrap_or(false),
encrypt_before_upload: false, // 9c (vault) is on hold
created_at: now_unix(),
};
state
.store
.add_sync_target(row.clone())
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
Ok(crate::sync::row_to_info(&row))
}
#[tauri::command]
pub async fn remove_sync_target(_id: String) -> WaResult<()> {
Err(not_implemented("remove_sync_target"))
pub async fn update_sync_target(
state: State<'_, AppState>,
config: SyncTargetConfigInput,
) -> WaResult<SyncTargetInfo> {
let id = config
.id
.clone()
.ok_or_else(|| WaError::new("sync", "update requires a target id"))?;
let mut row = state
.store
.get_sync_target(&id)
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
// A provided secret rotates the credential; credential_ref is preserved.
if let Some(secret) = config.secret.as_deref().filter(|s| !s.is_empty()) {
crate::sync::credentials::set(&row.credential_ref, secret)
.map_err(|e| WaError::new("sync", e.to_string()))?;
}
if let Some(v) = config.name {
row.name = v;
}
if let Some(v) = config.provider_hint {
row.provider_hint = Some(v);
}
if let Some(v) = config.base_url {
row.base_url = Some(v);
}
if let Some(v) = config.remote_base_path {
row.remote_base_path = v;
}
if let Some(v) = config.username {
row.username = Some(v);
}
if let Some(v) = config.enabled {
row.enabled = v;
}
if let Some(v) = config.upload_transcript {
row.upload_transcript = v;
}
if let Some(v) = config.upload_notes {
row.upload_notes = v;
}
if let Some(v) = config.upload_summary {
row.upload_summary = v;
}
if let Some(v) = config.upload_recording {
row.upload_recording = v;
}
if let Some(v) = config.trigger_on_finalize {
row.trigger_on_finalize = v;
}
if let Some(v) = config.allow_plaintext_lan {
row.allow_plaintext_lan = v;
}
state
.store
.update_sync_target(row.clone())
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
Ok(crate::sync::row_to_info(&row))
}
#[tauri::command]
pub async fn remove_sync_target(state: State<'_, AppState>, id: String) -> WaResult<()> {
// Best-effort secret cleanup first, then the row (cascades its jobs).
if let Ok(row) = state.store.get_sync_target(&id).await {
let _ = crate::sync::credentials::delete(&row.credential_ref);
}
state
.store
.remove_sync_target(&id)
.await
.map_err(|e| WaError::new("sync", e.to_string()))
}
/// Begin OAuth 2.0 PKCE linking for a secondary target (loopback redirect). FR-SYNC-9.
@@ -1784,14 +1913,81 @@ pub async fn retry_sync_job(_job_id: String) -> WaResult<()> {
Err(not_implemented("retry_sync_job"))
}
/// Provider-specific setup tips surfaced on a failed/attempted test (T9.4).
fn provider_setup_hints(hint: Option<&str>) -> Vec<&'static str> {
match hint {
Some("seafile") => {
vec!["Enable SeafDAV on the server; it's off by default and may need LOCK disabled."]
}
Some("synology") => {
vec!["Install & enable the WebDAV Server package; prefer the HTTPS port."]
}
Some("nextcloud") | Some("owncloud") => {
vec!["Use an app password (Settings → Security), not your account password."]
}
_ => vec![],
}
}
/// Reachability + auth test for a target — either an existing one (by `id`) or an
/// unsaved config carrying an inline `secret` (T9.4, FR-SYNC-4).
#[tauri::command]
pub async fn test_sync_target(_config_or_id: serde_json::Value) -> WaResult<serde_json::Value> {
Err(not_implemented("test_sync_target"))
pub async fn test_sync_target(
state: State<'_, AppState>,
config: SyncTargetConfigInput,
) -> WaResult<serde_json::Value> {
use crate::sync::SyncTarget;
let hints = provider_setup_hints(config.provider_hint.as_deref());
let (target, temp_ref) = if let Some(id) = config.id.clone() {
let row = state
.store
.get_sync_target(&id)
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
(crate::sync::WebDavTarget::from_row(&row), None)
} else {
// Unsaved target: stash the secret under a temp credential_ref so the same
// resolve-at-use path works, then clean it up after the test.
let base_url = config
.base_url
.clone()
.ok_or_else(|| WaError::new("sync", "test requires a base_url"))?;
let temp_ref = format!("wa-sync-test-{}", uuid::Uuid::new_v4());
if let Some(secret) = config.secret.as_deref().filter(|s| !s.is_empty()) {
crate::sync::credentials::set(&temp_ref, secret)
.map_err(|e| WaError::new("sync", e.to_string()))?;
}
let target = crate::sync::WebDavTarget {
base_url,
remote_base_path: config
.remote_base_path
.clone()
.unwrap_or_else(|| "/WhispAssist".to_string()),
username: config.username.clone().unwrap_or_default(),
credential_ref: temp_ref.clone(),
third_party: config.kind.as_deref().unwrap_or("webdav") != "webdav",
allow_plaintext_lan: config.allow_plaintext_lan.unwrap_or(false),
};
(target, Some(temp_ref))
};
let result = target.test().await;
if let Some(temp_ref) = temp_ref {
let _ = crate::sync::credentials::delete(&temp_ref);
}
match result {
Ok(()) => Ok(serde_json::json!({ "ok": true, "message": "Connected", "hints": hints })),
Err(e) => Ok(serde_json::json!({ "ok": false, "message": e.to_string(), "hints": hints })),
}
}
#[tauri::command]
pub async fn set_sync_enabled(_enabled: bool) -> WaResult<()> {
Err(not_implemented("set_sync_enabled"))
pub async fn set_sync_enabled(enabled: bool) -> WaResult<()> {
let mut settings = load_settings();
settings.sync_enabled = enabled;
save_settings(&settings)
}
#[tauri::command]
@@ -1954,13 +2150,18 @@ fn privacy_self_check_json(
}
/// Reports current egress + LLM endpoint so the UI can prove local-only handling (FR-SEC-2).
/// Sync targets/hosts are empty until Phase 9 lands `SyncManager`; `list_sync_targets` is still
/// `not_implemented` so its error is swallowed here rather than failing the whole self-check.
/// Enabled sync targets' hosts are folded into the allowlist (T9.8); a DB error is
/// swallowed to an empty list rather than failing the whole self-check.
#[tauri::command]
pub async fn privacy_self_check() -> WaResult<serde_json::Value> {
pub async fn privacy_self_check(state: State<'_, AppState>) -> WaResult<serde_json::Value> {
let settings = load_settings();
let sync_targets: Vec<SyncTargetInfo> = if settings.sync_enabled {
list_sync_targets().await.unwrap_or_default()
state
.store
.list_sync_targets()
.await
.map(|rows| rows.iter().map(crate::sync::row_to_info).collect())
.unwrap_or_default()
} else {
Vec::new()
};