1use crate::core_bridge::CoreBridge;
4use crate::error::{CollabError, Result};
5use crate::history::VersionControl;
6use crate::workspace::WorkspaceService;
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use sqlx::{Pool, Sqlite};
10use std::path::Path;
11use std::sync::Arc;
12use uuid::Uuid;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
16#[sqlx(type_name = "storage_backend", rename_all = "lowercase")]
17#[serde(rename_all = "lowercase")]
18pub enum StorageBackend {
19 Local,
21 S3,
23 Azure,
25 Gcs,
27 Custom,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
33pub struct WorkspaceBackup {
34 pub id: Uuid,
36 pub workspace_id: Uuid,
38 pub backup_url: String,
40 pub storage_backend: StorageBackend,
42 pub storage_config: Option<serde_json::Value>,
44 pub size_bytes: i64,
46 pub backup_format: String,
48 pub encrypted: bool,
50 pub commit_id: Option<Uuid>,
52 pub created_at: DateTime<Utc>,
54 pub created_by: Uuid,
56 pub expires_at: Option<DateTime<Utc>>,
58}
59
60impl WorkspaceBackup {
61 pub fn new(
63 workspace_id: Uuid,
64 backup_url: String,
65 storage_backend: StorageBackend,
66 size_bytes: i64,
67 created_by: Uuid,
68 ) -> Self {
69 Self {
70 id: Uuid::new_v4(),
71 workspace_id,
72 backup_url,
73 storage_backend,
74 storage_config: None,
75 size_bytes,
76 backup_format: "yaml".to_string(),
77 encrypted: false,
78 commit_id: None,
79 created_at: Utc::now(),
80 created_by,
81 expires_at: None,
82 }
83 }
84}
85
86pub struct BackupService {
88 db: Pool<Sqlite>,
89 version_control: VersionControl,
90 local_backup_dir: Option<String>,
91 core_bridge: Arc<CoreBridge>,
92 workspace_service: Arc<WorkspaceService>,
93}
94
95impl BackupService {
96 pub fn new(
98 db: Pool<Sqlite>,
99 local_backup_dir: Option<String>,
100 core_bridge: Arc<CoreBridge>,
101 workspace_service: Arc<WorkspaceService>,
102 ) -> Self {
103 Self {
104 db: db.clone(),
105 version_control: VersionControl::new(db),
106 local_backup_dir,
107 core_bridge,
108 workspace_service,
109 }
110 }
111
112 pub async fn backup_workspace(
118 &self,
119 workspace_id: Uuid,
120 user_id: Uuid,
121 storage_backend: StorageBackend,
122 format: Option<String>,
123 commit_id: Option<Uuid>,
124 ) -> Result<WorkspaceBackup> {
125 let workspace = self
127 .workspace_service
128 .get_workspace(workspace_id)
129 .await
130 .map_err(|e| CollabError::Internal(format!("Failed to get workspace: {}", e)))?;
131
132 let workspace_data = self
136 .core_bridge
137 .export_workspace_for_backup(&workspace)
138 .await
139 .map_err(|e| CollabError::Internal(format!("Failed to export workspace: {}", e)))?;
140
141 let backup_format = format.unwrap_or_else(|| "yaml".to_string());
143 let serialized = match backup_format.as_str() {
144 "yaml" => serde_yaml::to_string(&workspace_data).map_err(|e| {
145 CollabError::Internal(format!("Failed to serialize to YAML: {}", e))
146 })?,
147 "json" => serde_json::to_string_pretty(&workspace_data).map_err(|e| {
148 CollabError::Internal(format!("Failed to serialize to JSON: {}", e))
149 })?,
150 _ => {
151 return Err(CollabError::InvalidInput(format!(
152 "Unsupported backup format: {}",
153 backup_format
154 )));
155 }
156 };
157
158 let size_bytes = serialized.len() as i64;
159
160 let backup_url = match storage_backend {
162 StorageBackend::Local => {
163 self.save_to_local(workspace_id, &serialized, &backup_format).await?
164 }
165 StorageBackend::S3 => {
166 return Err(CollabError::Internal("S3 backup not yet implemented".to_string()));
167 }
168 StorageBackend::Azure => {
169 return Err(CollabError::Internal("Azure backup not yet implemented".to_string()));
170 }
171 StorageBackend::Gcs => {
172 return Err(CollabError::Internal("GCS backup not yet implemented".to_string()));
173 }
174 StorageBackend::Custom => {
175 return Err(CollabError::Internal(
176 "Custom storage backend not yet implemented".to_string(),
177 ));
178 }
179 };
180
181 let mut backup =
183 WorkspaceBackup::new(workspace_id, backup_url, storage_backend, size_bytes, user_id);
184 backup.backup_format = backup_format;
185 backup.commit_id = commit_id;
186
187 sqlx::query!(
189 r#"
190 INSERT INTO workspace_backups (
191 id, workspace_id, backup_url, storage_backend, storage_config,
192 size_bytes, backup_format, encrypted, commit_id, created_at, created_by, expires_at
193 )
194 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
195 "#,
196 backup.id,
197 backup.workspace_id,
198 backup.backup_url,
199 backup.storage_backend,
200 backup.storage_config,
201 backup.size_bytes,
202 backup.backup_format,
203 backup.encrypted,
204 backup.commit_id,
205 backup.created_at,
206 backup.created_by,
207 backup.expires_at
208 )
209 .execute(&self.db)
210 .await?;
211
212 Ok(backup)
213 }
214
215 pub async fn restore_workspace(
217 &self,
218 backup_id: Uuid,
219 target_workspace_id: Option<Uuid>,
220 user_id: Uuid,
221 ) -> Result<Uuid> {
222 let backup = self.get_backup(backup_id).await?;
224
225 let backup_data = match backup.storage_backend {
227 StorageBackend::Local => self.load_from_local(&backup.backup_url).await?,
228 _ => {
229 return Err(CollabError::Internal(
230 "Only local backups are supported for restore".to_string(),
231 ));
232 }
233 };
234
235 let workspace_data: serde_json::Value = match backup.backup_format.as_str() {
237 "yaml" => serde_yaml::from_str(&backup_data)
238 .map_err(|e| CollabError::Internal(format!("Failed to deserialize YAML: {}", e)))?,
239 "json" => serde_json::from_str(&backup_data)
240 .map_err(|e| CollabError::Internal(format!("Failed to deserialize JSON: {}", e)))?,
241 _ => {
242 return Err(CollabError::Internal(format!(
243 "Unsupported backup format: {}",
244 backup.backup_format
245 )));
246 }
247 };
248
249 let backup_record = self.get_backup(backup_id).await?;
252 let owner_id = backup_record.created_by;
253
254 let restored_team_workspace = self
256 .core_bridge
257 .import_workspace_from_backup(&workspace_data, owner_id, None)
258 .await?;
259
260 let restored_workspace_id = target_workspace_id.unwrap_or(backup.workspace_id);
262
263 let mut team_workspace = if restored_workspace_id != backup.workspace_id {
265 let mut new_workspace = restored_team_workspace;
267 new_workspace.id = restored_workspace_id;
268 new_workspace
269 } else {
270 restored_team_workspace
272 };
273
274 self.core_bridge.save_workspace_to_disk(&team_workspace).await?;
278
279 if let Some(commit_id) = backup.commit_id {
281 let _ =
283 self.version_control.restore_to_commit(restored_workspace_id, commit_id).await?;
284 }
285
286 Ok(restored_workspace_id)
287 }
288
289 pub async fn list_backups(
291 &self,
292 workspace_id: Uuid,
293 limit: Option<i32>,
294 ) -> Result<Vec<WorkspaceBackup>> {
295 let limit = limit.unwrap_or(100);
296 let workspace_id_str = workspace_id.to_string();
297
298 let rows = sqlx::query!(
299 r#"
300 SELECT
301 id,
302 workspace_id,
303 backup_url,
304 storage_backend,
305 storage_config,
306 size_bytes,
307 backup_format,
308 encrypted,
309 commit_id,
310 created_at,
311 created_by,
312 expires_at
313 FROM workspace_backups
314 WHERE workspace_id = ?
315 ORDER BY created_at DESC
316 LIMIT ?
317 "#,
318 workspace_id_str,
319 limit
320 )
321 .fetch_all(&self.db)
322 .await?;
323
324 let backups: Result<Vec<WorkspaceBackup>> = rows
325 .into_iter()
326 .map(|row| {
327 Ok(WorkspaceBackup {
328 id: Uuid::parse_str(&row.id)
329 .map_err(|e| CollabError::Internal(format!("Invalid UUID: {}", e)))?,
330 workspace_id: Uuid::parse_str(&row.workspace_id)
331 .map_err(|e| CollabError::Internal(format!("Invalid UUID: {}", e)))?,
332 backup_url: row.backup_url,
333 storage_backend: serde_json::from_str(&row.storage_backend).map_err(|e| {
334 CollabError::Internal(format!("Invalid storage_backend: {}", e))
335 })?,
336 storage_config: row
337 .storage_config
338 .as_ref()
339 .and_then(|s| serde_json::from_str(s).ok()),
340 size_bytes: row.size_bytes,
341 backup_format: row.backup_format,
342 encrypted: row.encrypted != 0,
343 commit_id: row.commit_id.as_ref().and_then(|s| Uuid::parse_str(s).ok()),
344 created_at: chrono::DateTime::parse_from_rfc3339(&row.created_at)
345 .map_err(|e| CollabError::Internal(format!("Invalid timestamp: {}", e)))?
346 .with_timezone(&chrono::Utc),
347 created_by: Uuid::parse_str(&row.created_by)
348 .map_err(|e| CollabError::Internal(format!("Invalid UUID: {}", e)))?,
349 expires_at: row
350 .expires_at
351 .as_ref()
352 .map(|s| {
353 chrono::DateTime::parse_from_rfc3339(s)
354 .map(|dt| dt.with_timezone(&chrono::Utc))
355 .map_err(|e| {
356 CollabError::Internal(format!("Invalid timestamp: {}", e))
357 })
358 })
359 .transpose()?,
360 })
361 })
362 .collect();
363 let backups = backups?;
364
365 Ok(backups)
366 }
367
368 pub async fn get_backup(&self, backup_id: Uuid) -> Result<WorkspaceBackup> {
370 let backup_id_str = backup_id.to_string();
371 let row = sqlx::query!(
372 r#"
373 SELECT
374 id,
375 workspace_id,
376 backup_url,
377 storage_backend,
378 storage_config,
379 size_bytes,
380 backup_format,
381 encrypted,
382 commit_id,
383 created_at,
384 created_by,
385 expires_at
386 FROM workspace_backups
387 WHERE id = ?
388 "#,
389 backup_id_str
390 )
391 .fetch_optional(&self.db)
392 .await?
393 .ok_or_else(|| CollabError::Internal(format!("Backup not found: {}", backup_id)))?;
394
395 Ok(WorkspaceBackup {
396 id: Uuid::parse_str(&row.id)
397 .map_err(|e| CollabError::Internal(format!("Invalid UUID: {}", e)))?,
398 workspace_id: Uuid::parse_str(&row.workspace_id)
399 .map_err(|e| CollabError::Internal(format!("Invalid UUID: {}", e)))?,
400 backup_url: row.backup_url,
401 storage_backend: serde_json::from_str(&row.storage_backend)
402 .map_err(|e| CollabError::Internal(format!("Invalid storage_backend: {}", e)))?,
403 storage_config: row.storage_config.as_ref().and_then(|s| serde_json::from_str(s).ok()),
404 size_bytes: row.size_bytes,
405 backup_format: row.backup_format,
406 encrypted: row.encrypted != 0,
407 commit_id: row.commit_id.as_ref().and_then(|s| Uuid::parse_str(s).ok()),
408 created_at: chrono::DateTime::parse_from_rfc3339(&row.created_at)
409 .map_err(|e| CollabError::Internal(format!("Invalid timestamp: {}", e)))?
410 .with_timezone(&chrono::Utc),
411 created_by: Uuid::parse_str(&row.created_by)
412 .map_err(|e| CollabError::Internal(format!("Invalid UUID: {}", e)))?,
413 expires_at: row
414 .expires_at
415 .as_ref()
416 .map(|s| {
417 chrono::DateTime::parse_from_rfc3339(s)
418 .map(|dt| dt.with_timezone(&chrono::Utc))
419 .map_err(|e| CollabError::Internal(format!("Invalid timestamp: {}", e)))
420 })
421 .transpose()?,
422 })
423 }
424
425 pub async fn delete_backup(&self, backup_id: Uuid) -> Result<()> {
427 let backup = self.get_backup(backup_id).await?;
429
430 match backup.storage_backend {
432 StorageBackend::Local => {
433 if Path::new(&backup.backup_url).exists() {
434 tokio::fs::remove_file(&backup.backup_url).await.map_err(|e| {
435 CollabError::Internal(format!("Failed to delete backup file: {}", e))
436 })?;
437 }
438 }
439 StorageBackend::S3 => {
440 self.delete_from_s3(&backup.backup_url, backup.storage_config.as_ref()).await?;
441 }
442 StorageBackend::Azure => {
443 self.delete_from_azure(&backup.backup_url, backup.storage_config.as_ref())
444 .await?;
445 }
446 StorageBackend::Gcs => {
447 self.delete_from_gcs(&backup.backup_url, backup.storage_config.as_ref()).await?;
448 }
449 StorageBackend::Custom => {
450 return Err(CollabError::Internal(
451 "Custom storage backend deletion not implemented".to_string(),
452 ));
453 }
454 }
455
456 let backup_id_str = backup_id.to_string();
458 sqlx::query!(
459 r#"
460 DELETE FROM workspace_backups
461 WHERE id = ?
462 "#,
463 backup_id_str
464 )
465 .execute(&self.db)
466 .await?;
467
468 Ok(())
469 }
470
471 async fn save_to_local(&self, workspace_id: Uuid, data: &str, format: &str) -> Result<String> {
473 let backup_dir = self.local_backup_dir.as_ref().ok_or_else(|| {
474 CollabError::Internal("Local backup directory not configured".to_string())
475 })?;
476
477 tokio::fs::create_dir_all(backup_dir).await.map_err(|e| {
479 CollabError::Internal(format!("Failed to create backup directory: {}", e))
480 })?;
481
482 let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
484 let filename = format!("workspace_{}_{}.{}", workspace_id, timestamp, format);
485 let backup_path = Path::new(backup_dir).join(&filename);
486
487 tokio::fs::write(&backup_path, data)
489 .await
490 .map_err(|e| CollabError::Internal(format!("Failed to write backup file: {}", e)))?;
491
492 Ok(backup_path.to_string_lossy().to_string())
493 }
494
495 async fn load_from_local(&self, backup_url: &str) -> Result<String> {
497 tokio::fs::read_to_string(backup_url)
498 .await
499 .map_err(|e| CollabError::Internal(format!("Failed to read backup file: {}", e)))
500 }
501
502 async fn delete_from_s3(
504 &self,
505 backup_url: &str,
506 storage_config: Option<&serde_json::Value>,
507 ) -> Result<()> {
508 if !backup_url.starts_with("s3://") {
510 return Err(CollabError::Internal(format!("Invalid S3 URL format: {}", backup_url)));
511 }
512
513 let url_parts: Vec<&str> =
514 backup_url.strip_prefix("s3://").unwrap().splitn(2, '/').collect();
515 if url_parts.len() != 2 {
516 return Err(CollabError::Internal(format!("Invalid S3 URL format: {}", backup_url)));
517 }
518
519 let bucket = url_parts[0];
520 let key = url_parts[1];
521
522 let _access_key = storage_config
525 .and_then(|c| c.get("access_key_id"))
526 .and_then(|v| v.as_str())
527 .ok_or_else(|| {
528 CollabError::Internal("S3 access_key_id not found in storage_config".to_string())
529 })?;
530
531 let _secret_key = storage_config
532 .and_then(|c| c.get("secret_access_key"))
533 .and_then(|v| v.as_str())
534 .ok_or_else(|| {
535 CollabError::Internal(
536 "S3 secret_access_key not found in storage_config".to_string(),
537 )
538 })?;
539
540 tracing::warn!(
543 "S3 deletion requires aws-sdk-s3. Backup URL: {}, Bucket: {}, Key: {}",
544 backup_url,
545 bucket,
546 key
547 );
548
549 Err(CollabError::Internal(
556 "S3 deletion requires aws-sdk-s3 dependency. Please install it to enable S3 support."
557 .to_string(),
558 ))
559 }
560
561 async fn delete_from_azure(
563 &self,
564 backup_url: &str,
565 storage_config: Option<&serde_json::Value>,
566 ) -> Result<()> {
567 if !backup_url.contains("blob.core.windows.net") {
569 return Err(CollabError::Internal(format!(
570 "Invalid Azure Blob URL format: {}",
571 backup_url
572 )));
573 }
574
575 let url_parts: Vec<&str> = backup_url.splitn(4, '/').collect();
578 if url_parts.len() < 4 {
579 return Err(CollabError::Internal(format!(
580 "Invalid Azure Blob URL format: {}",
581 backup_url
582 )));
583 }
584
585 let hostname = url_parts[2];
587 let _account = hostname
588 .split('.')
589 .next()
590 .ok_or_else(|| CollabError::Internal("Invalid Azure hostname".to_string()))?;
591
592 let _container = url_parts[3]
593 .split('/')
594 .next()
595 .ok_or_else(|| CollabError::Internal("Invalid Azure container path".to_string()))?;
596
597 let _blob_name = backup_url
598 .splitn(5, '/')
599 .nth(4)
600 .ok_or_else(|| CollabError::Internal("Invalid Azure blob path".to_string()))?;
601
602 let _account_name = storage_config
605 .and_then(|c| c.get("account_name"))
606 .and_then(|v| v.as_str())
607 .ok_or_else(|| {
608 CollabError::Internal("Azure account_name not found in storage_config".to_string())
609 })?;
610
611 tracing::warn!("Azure deletion requires azure-storage-blobs. Backup URL: {}", backup_url);
613
614 Err(CollabError::Internal(
616 "Azure deletion requires azure-storage-blobs dependency. Please install it to enable Azure support.".to_string(),
617 ))
618 }
619
620 async fn delete_from_gcs(
622 &self,
623 backup_url: &str,
624 storage_config: Option<&serde_json::Value>,
625 ) -> Result<()> {
626 if !backup_url.starts_with("gs://") {
628 return Err(CollabError::Internal(format!("Invalid GCS URL format: {}", backup_url)));
629 }
630
631 let url_parts: Vec<&str> =
632 backup_url.strip_prefix("gs://").unwrap().splitn(2, '/').collect();
633 if url_parts.len() != 2 {
634 return Err(CollabError::Internal(format!("Invalid GCS URL format: {}", backup_url)));
635 }
636
637 let _bucket = url_parts[0];
638 let _object_name = url_parts[1];
639
640 let _project_id = storage_config
643 .and_then(|c| c.get("project_id"))
644 .and_then(|v| v.as_str())
645 .ok_or_else(|| {
646 CollabError::Internal("GCS project_id not found in storage_config".to_string())
647 })?;
648
649 tracing::warn!("GCS deletion requires google-cloud-storage. Backup URL: {}", backup_url);
651
652 Err(CollabError::Internal(
654 "GCS deletion requires google-cloud-storage dependency. Please install it to enable GCS support.".to_string(),
655 ))
656 }
657
658 async fn get_workspace_data(&self, workspace_id: Uuid) -> Result<serde_json::Value> {
662 let team_workspace = self.workspace_service.get_workspace(workspace_id).await?;
664
665 self.core_bridge.get_workspace_state_json(&team_workspace)
667 }
668}