Browse Source

Improved backup/restore module

maziggy 4 months ago
parent
commit
372df9f06a

+ 117 - 4
backend/app/api/routes/settings.py

@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
+from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
 from backend.app.models.external_link import ExternalLink
 from backend.app.models.filament import Filament
@@ -228,6 +229,7 @@ async def export_backup(
     include_projects: bool = Query(False, description="Include projects with BOM items"),
     include_pending_uploads: bool = Query(False, description="Include pending virtual printer uploads"),
     include_access_codes: bool = Query(False, description="Include printer access codes (security risk!)"),
+    include_api_keys: bool = Query(False, description="Include API keys (keys will need to be regenerated on import)"),
 ):
     """Export selected data as JSON backup."""
     backup: dict = {
@@ -527,12 +529,13 @@ async def export_backup(
             )
         backup["included"].append("print_queue")
 
-    # Collect files for ZIP (icons + archives)
+    # Collect files for ZIP (icons + archives + project attachments)
     backup_files: list[tuple[str, Path]] = []  # (zip_path, local_path)
+    base_dir = app_settings.base_dir
 
     # Add external link icon files
     if include_external_links and "external_links" in backup:
-        icons_dir = app_settings.base_dir / "icons"
+        icons_dir = base_dir / "icons"
         for link_data in backup["external_links"]:
             if "custom_icon_path" in link_data:
                 icon_path = icons_dir / link_data["custom_icon"]
@@ -544,7 +547,6 @@ async def export_backup(
         result = await db.execute(select(PrintArchive))
         archives = result.scalars().all()
         backup["archives"] = []
-        base_dir = app_settings.base_dir
 
         # Build project ID to name mapping for archive export
         project_id_to_name: dict[int, str] = {}
@@ -713,6 +715,39 @@ async def export_backup(
             backup["pending_uploads"].append(upload_data)
         backup["included"].append("pending_uploads")
 
+    # API keys (note: key_hash cannot be restored, new keys must be generated)
+    if include_api_keys:
+        # Build printer ID to serial mapping for cross-system compatibility
+        printer_id_to_serial: dict[int, str] = {}
+        pr_result = await db.execute(select(Printer))
+        for pr in pr_result.scalars().all():
+            printer_id_to_serial[pr.id] = pr.serial_number
+
+        result = await db.execute(select(APIKey))
+        api_keys = result.scalars().all()
+        backup["api_keys"] = []
+        for key in api_keys:
+            # Convert printer_ids from list of IDs to list of serials
+            printer_serials = None
+            if key.printer_ids:
+                printer_serials = [
+                    printer_id_to_serial.get(pid) for pid in key.printer_ids if pid in printer_id_to_serial
+                ]
+
+            backup["api_keys"].append(
+                {
+                    "name": key.name,
+                    "key_prefix": key.key_prefix,  # For identification only
+                    "can_queue": key.can_queue,
+                    "can_control_printer": key.can_control_printer,
+                    "can_read_status": key.can_read_status,
+                    "printer_serials": printer_serials,  # Use serials instead of IDs
+                    "enabled": key.enabled,
+                    "expires_at": key.expires_at.isoformat() if key.expires_at else None,
+                }
+            )
+        backup["included"].append("api_keys")
+
     # If there are files to include (icons or archives), create ZIP file
     if backup_files:
         zip_buffer = io.BytesIO()
@@ -1598,6 +1633,78 @@ async def import_backup(
                 db.add(pending)
                 restored["pending_uploads"] += 1
 
+    # Restore API keys (generates new keys since we can't restore the hash)
+    new_api_keys: list[dict] = []  # Track newly generated keys for response
+    if "api_keys" in backup:
+        from backend.app.core.auth import generate_api_key
+
+        # Build printer serial to ID mapping
+        printer_serial_to_id: dict[str, int] = {}
+        pr_result = await db.execute(select(Printer))
+        for pr in pr_result.scalars().all():
+            printer_serial_to_id[pr.serial_number] = pr.id
+
+        restored["api_keys"] = 0
+        skipped["api_keys"] = 0
+        skipped_details["api_keys"] = []
+
+        for key_data in backup["api_keys"]:
+            # Check if key with same name already exists
+            result = await db.execute(select(APIKey).where(APIKey.name == key_data["name"]))
+            existing = result.scalar_one_or_none()
+            if existing:
+                if overwrite:
+                    # Update permissions but keep the existing key
+                    existing.can_queue = key_data.get("can_queue", True)
+                    existing.can_control_printer = key_data.get("can_control_printer", False)
+                    existing.can_read_status = key_data.get("can_read_status", True)
+                    existing.enabled = key_data.get("enabled", True)
+                    if key_data.get("expires_at"):
+                        existing.expires_at = datetime.fromisoformat(key_data["expires_at"])
+                    # Convert printer serials to IDs
+                    if key_data.get("printer_serials"):
+                        existing.printer_ids = [
+                            printer_serial_to_id[s] for s in key_data["printer_serials"] if s in printer_serial_to_id
+                        ]
+                    restored["api_keys"] += 1
+                else:
+                    skipped["api_keys"] += 1
+                    skipped_details["api_keys"].append(key_data["name"])
+            else:
+                # Generate new key
+                full_key, key_hash, key_prefix = generate_api_key()
+
+                # Convert printer serials to IDs
+                printer_ids = None
+                if key_data.get("printer_serials"):
+                    printer_ids = [
+                        printer_serial_to_id[s] for s in key_data["printer_serials"] if s in printer_serial_to_id
+                    ]
+
+                api_key = APIKey(
+                    name=key_data["name"],
+                    key_hash=key_hash,
+                    key_prefix=key_prefix,
+                    can_queue=key_data.get("can_queue", True),
+                    can_control_printer=key_data.get("can_control_printer", False),
+                    can_read_status=key_data.get("can_read_status", True),
+                    printer_ids=printer_ids,
+                    enabled=key_data.get("enabled", True),
+                )
+                if key_data.get("expires_at"):
+                    api_key.expires_at = datetime.fromisoformat(key_data["expires_at"])
+                db.add(api_key)
+                restored["api_keys"] += 1
+
+                # Track the new key so user can see it
+                new_api_keys.append(
+                    {
+                        "name": key_data["name"],
+                        "key": full_key,
+                        "key_prefix": key_prefix,
+                    }
+                )
+
     await db.commit()
 
     # If printers were in the backup (restored, updated, or skipped), reconnect all active printers
@@ -1694,7 +1801,7 @@ async def import_backup(
     if skipped_parts:
         message_parts.append(f"Skipped (already exist): {', '.join(skipped_parts)}")
 
-    return {
+    response = {
         "success": True,
         "message": ". ".join(message_parts) if message_parts else "Nothing to restore",
         "restored": restored,
@@ -1704,6 +1811,12 @@ async def import_backup(
         "total_skipped": total_skipped,
     }
 
+    # Include newly generated API keys if any (so user can see them)
+    if new_api_keys:
+        response["new_api_keys"] = new_api_keys
+
+    return response
+
 
 # =============================================================================
 # Virtual Printer Settings

+ 1 - 1
backend/app/core/config.py

@@ -5,7 +5,7 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 
 # Application version - single source of truth
-APP_VERSION = "0.1.6b7"
+APP_VERSION = "0.1.6b8"
 GITHUB_REPO = "maziggy/bambuddy"
 
 # App directory - where the application is installed (for static files)

+ 8 - 0
frontend/src/api/client.ts

@@ -1816,11 +1816,19 @@ export const api = {
       if (categories.maintenance !== undefined) params.set('include_maintenance', String(categories.maintenance));
       if (categories.archives !== undefined) params.set('include_archives', String(categories.archives));
       if (categories.projects !== undefined) params.set('include_projects', String(categories.projects));
+      if (categories.pending_uploads !== undefined) params.set('include_pending_uploads', String(categories.pending_uploads));
       if (categories.access_codes !== undefined) params.set('include_access_codes', String(categories.access_codes));
+      if (categories.api_keys !== undefined) params.set('include_api_keys', String(categories.api_keys));
     }
     const url = `${API_BASE}/settings/backup${params.toString() ? '?' + params.toString() : ''}`;
     const response = await fetch(url);
 
+    // Check for errors
+    if (!response.ok) {
+      const errorText = await response.text();
+      throw new Error(errorText || `Backup failed with status ${response.status}`);
+    }
+
     // Get filename from Content-Disposition header
     const contentDisposition = response.headers.get('Content-Disposition');
     let filename = 'bambuddy-backup.json';

+ 8 - 0
frontend/src/components/BackupModal.tsx

@@ -103,6 +103,14 @@ const BACKUP_CATEGORIES: BackupCategory[] = [
     default: false,
     description: 'Virtual printer uploads awaiting review',
   },
+  {
+    id: 'api_keys',
+    labelKey: 'backup.categories.apiKeys',
+    defaultLabel: 'API Keys',
+    icon: <Key className="w-4 h-4" />,
+    default: false,
+    description: 'Webhook API keys (new keys generated on import)',
+  },
 ];
 
 interface BackupModalProps {

+ 55 - 7
frontend/src/components/RestoreModal.tsx

@@ -12,6 +12,7 @@ interface RestoreResult {
   skipped_details?: Record<string, string[]>;
   files_restored?: number;
   total_skipped?: number;
+  new_api_keys?: Array<{ name: string; key: string; key_prefix: string }>;
 }
 
 interface RestoreModalProps {
@@ -34,6 +35,7 @@ const CATEGORY_LABELS: Record<string, string> = {
   projects: 'Projects',
   pending_uploads: 'Pending Uploads',
   external_links: 'External Links',
+  api_keys: 'API Keys',
 };
 
 export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProps) {
@@ -46,11 +48,17 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
 
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
-      if (e.key === 'Escape' && state !== 'restoring') onClose();
+      if (e.key === 'Escape' && state !== 'restoring') {
+        // Use handleClose for result state to trigger onSuccess
+        if (state === 'result' && result?.success) {
+          onSuccess();
+        }
+        onClose();
+      }
     };
     window.addEventListener('keydown', handleKeyDown);
     return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [onClose, state]);
+  }, [onClose, onSuccess, state, result]);
 
   const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
     const file = e.target.files?.[0];
@@ -67,9 +75,8 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
       const restoreResult = await onRestore(selectedFile, overwrite);
       setResult(restoreResult);
       setState('result');
-      if (restoreResult.success) {
-        onSuccess();
-      }
+      // Don't call onSuccess here - wait until modal closes
+      // This prevents race condition with query cache
     } catch {
       setResult({
         success: false,
@@ -79,6 +86,14 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
     }
   };
 
+  const handleClose = () => {
+    // If restore was successful, trigger refresh before closing
+    if (result?.success) {
+      onSuccess();
+    }
+    onClose();
+  };
+
   const toggleCategory = (category: string) => {
     setExpandedCategories(prev => {
       const next = new Set(prev);
@@ -141,7 +156,7 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
             </div>
             {state !== 'restoring' && (
               <button
-                onClick={onClose}
+                onClick={handleClose}
                 className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
               >
                 <X className="w-5 h-5" />
@@ -352,6 +367,39 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
                   </div>
                 )}
 
+                {/* Newly Generated API Keys */}
+                {result.new_api_keys && result.new_api_keys.length > 0 && (
+                  <div className="space-y-2">
+                    <h4 className="text-sm font-medium text-bambu-gray flex items-center gap-2">
+                      <AlertTriangle className="w-4 h-4 text-orange-500" />
+                      New API Keys Generated
+                    </h4>
+                    <div className="p-3 rounded bg-orange-500/10 border border-orange-500/30">
+                      <p className="text-xs text-orange-200 mb-2">
+                        These keys are only shown once. Copy them now!
+                      </p>
+                      <div className="space-y-2">
+                        {result.new_api_keys.map((apiKey: { name: string; key: string; key_prefix: string }, i: number) => (
+                          <div key={i} className="p-2 rounded bg-bambu-dark">
+                            <div className="text-sm text-white font-medium mb-1">{apiKey.name}</div>
+                            <div className="flex items-center gap-2">
+                              <code className="text-xs text-bambu-green bg-bambu-dark-tertiary px-2 py-1 rounded font-mono flex-1 break-all">
+                                {apiKey.key}
+                              </code>
+                              <button
+                                onClick={() => navigator.clipboard.writeText(apiKey.key)}
+                                className="text-xs text-bambu-gray hover:text-white px-2 py-1 rounded bg-bambu-dark-tertiary"
+                              >
+                                Copy
+                              </button>
+                            </div>
+                          </div>
+                        ))}
+                      </div>
+                    </div>
+                  </div>
+                )}
+
                 {totalRestored === 0 && totalSkipped === 0 && (
                   <div className="p-4 text-center text-bambu-gray">
                     No data was found to restore in the backup file.
@@ -361,7 +409,7 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
 
               {/* Footer */}
               <div className="flex items-center justify-end gap-3 p-4 border-t border-bambu-dark-tertiary">
-                <Button onClick={onClose}>
+                <Button onClick={handleClose}>
                   Close
                 </Button>
               </div>

+ 12 - 1
frontend/src/pages/SettingsPage.tsx

@@ -2435,7 +2435,18 @@ export function SettingsPage() {
             // Reset local settings to force re-sync from restored data
             setLocalSettings(null);
             isInitialLoadRef.current = true;
-            queryClient.invalidateQueries();
+            // Use resetQueries to clear cached data completely
+            // This ensures fresh data is fetched, not stale cache
+            queryClient.resetQueries({ queryKey: ['settings'] });
+            // Invalidate other queries that may have changed
+            queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
+            queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
+            queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
+            queryClient.invalidateQueries({ queryKey: ['external-links'] });
+            queryClient.invalidateQueries({ queryKey: ['printers'] });
+            queryClient.invalidateQueries({ queryKey: ['filaments'] });
+            queryClient.invalidateQueries({ queryKey: ['maintenance-types'] });
+            queryClient.invalidateQueries({ queryKey: ['api-keys'] });
           }}
         />
       )}

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DAZKHkJ3.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-WNO7X-o0.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DvXWbCbD.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DSlUhPr3.css">
+    <script type="module" crossorigin src="/assets/index-WNO7X-o0.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DAZKHkJ3.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff