feat(sync): target management + test-connection commands; wire allowlist (T9.3/9.8)
This commit is contained in:
+222
-21
@@ -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()
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user