Browse Source

security(github-backup): refuse to save against a non-private repository

  While auditing real-world Bambuddy backup repos on GitHub I found
  several left public. That's a serious leak: the settings backup only
  filters bambu_cloud_token and auth_secret_key, so mqtt_username,
  mqtt_password, ha_token, prometheus_token, bambu_cloud_email,
  external_url, and the printer access codes (via K-profiles) were going
  to whatever visibility the user picked.

  Hard guard at every save and re-checked on every push:

  - POST /github-backup/config and PATCH /github-backup/config (when URL,
    token, or provider changes) run a connection test internally and
    return 400 unless is_private comes back True.
  - run_backup() re-checks before each scheduled or manual push, so a
    repository that flipped from private to public gets a clear
    "Backup aborted: the target repository is no longer private" failure.

  Each provider's test_connection now returns is_private (GitHub /
  Gitea / Forgejo read data.private, GitLab reads visibility=="private";
  "internal" is treated as non-private). None means "couldn't determine"
  and is also rejected -- safer to fail closed.

  Frontend renders visibility inline on Test Connection: green check when
  private, red warning panel listing every credential at risk when public,
  yellow when unknown.

---

  ui(github-backup): show save-failure messages inline on the card

  The new "repository is not private" rejection message is ~250 characters
  listing every credential the backup carries (MQTT password, HA token,
  Prometheus token, Bambu Cloud email, printer access codes), which clips
  badly in a toast.

  Both the initial-setup save and the debounced autosave now stash the
  backend's error message into a saveError state and render it as a red
  inline banner above the test-result block, with whitespace-pre-wrap so
  the full message stays readable. The banner clears on success, on the
  next save attempt, and when the user starts editing URL / token / provider
  -- the three fields whose changes invalidate the privacy check -- so it
  doesn't linger after the user has already addressed the cause.

  Short success toasts (Settings saved, Token updated, Backup enabled) are
  unchanged.
maziggy 1 week ago
parent
commit
48a7024b96

File diff suppressed because it is too large
+ 4 - 0
CHANGELOG.md


+ 57 - 0
backend/app/api/routes/github_backup.py

@@ -28,6 +28,39 @@ logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/github-backup", tags=["github-backup"])
 
 
+_PUBLIC_REPO_ERROR = (
+    "Refusing to save: the target repository is not private. Bambuddy backups "
+    "include MQTT credentials, Home Assistant tokens, Prometheus tokens, your "
+    "Bambu Cloud email, the printer access codes via K-profiles, and other "
+    "settings that must not be exposed publicly. Make the repository private "
+    "in your provider's UI and try again."
+)
+_UNKNOWN_VISIBILITY_ERROR = (
+    "Refusing to save: could not confirm the target repository is private. "
+    "Bambuddy backups contain credentials and must never go to a public or "
+    "internal-visibility repository. Verify the URL, the access token's scope, "
+    "and that your provider exposes the 'private' / 'visibility' field on its "
+    "repo API."
+)
+
+
+async def _enforce_private_repo(repo_url: str, token: str, provider: str) -> None:
+    """Run a test_connection and refuse if the repo is not confirmed private.
+
+    Used by POST and PATCH /config so a backup configuration can never be
+    saved against a public repository.
+    """
+    result = await github_backup_service.test_connection(repo_url, token, provider=provider)
+    if not result.get("success"):
+        message = result.get("message") or "Connection test failed"
+        raise HTTPException(status_code=400, detail=f"Cannot verify repository: {message}")
+    is_private = result.get("is_private")
+    if is_private is None:
+        raise HTTPException(status_code=400, detail=_UNKNOWN_VISIBILITY_ERROR)
+    if is_private is False:
+        raise HTTPException(status_code=400, detail=_PUBLIC_REPO_ERROR)
+
+
 def _config_to_response(config: GitHubBackupConfig) -> dict:
     """Convert config model to response dict."""
     return {
@@ -79,7 +112,16 @@ async def save_config(
     """Create or update GitHub backup configuration.
 
     Only one configuration is supported. If one exists, it will be updated.
+    The target repository must be private — Bambuddy backups carry MQTT
+    credentials, HA/Prometheus tokens, the Bambu Cloud email, and printer
+    access codes (via K-profiles), so a public repo is a hard reject.
     """
+    await _enforce_private_repo(
+        config_data.repository_url,
+        config_data.access_token,
+        config_data.provider.value,
+    )
+
     # Check for existing config
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     config = result.scalar_one_or_none()
@@ -163,6 +205,21 @@ async def update_config(
                 detail="This URL uses HTTP instead of HTTPS. Enable 'Allow insecure HTTP' if your instance does not use TLS.",
             )
 
+    # Re-verify the repo is private whenever the target changes — new URL,
+    # new token, or new provider. We DON'T re-test on every unrelated PATCH
+    # (e.g. toggling backup_archives) so flipping schedule settings doesn't
+    # round-trip a live API call.
+    target_changed = "repository_url" in update_dict or "access_token" in update_dict or "provider" in update_dict
+    if target_changed:
+        provider_value = update_dict.get("provider", config.provider)
+        if hasattr(provider_value, "value"):
+            provider_value = provider_value.value
+        await _enforce_private_repo(
+            update_dict.get("repository_url", config.repository_url),
+            update_dict.get("access_token", config.access_token),
+            provider_value,
+        )
+
     for key, value in update_dict.items():
         if key in ("schedule_type", "provider") and value is not None:
             setattr(config, key, value.value)

+ 5 - 0
backend/app/schemas/github_backup.py

@@ -176,6 +176,11 @@ class GitHubTestConnectionResponse(BaseModel):
     message: str
     repo_name: str | None = None
     permissions: dict | None = None
+    # True = confirmed private. False = confirmed public (or non-private such
+    # as GitLab "internal"). None = could not be determined (older self-hosted
+    # API, non-2xx response). The backup config endpoints refuse anything that
+    # isn't an explicit True.
+    is_private: bool | None = None
 
 
 class GitHubBackupTriggerResponse(BaseModel):

+ 4 - 0
backend/app/services/git_providers/forgejo.py

@@ -69,6 +69,7 @@ class ForgejoBackend(GiteaBackend):
 
             data = repo_resp.json()
             permissions = data.get("permissions", {})
+            is_private = bool(data.get("private", False))
 
             if not permissions.get("push", False):
                 return {
@@ -76,6 +77,7 @@ class ForgejoBackend(GiteaBackend):
                     "message": "Token does not have push permission to this repository",
                     "repo_name": data.get("full_name"),
                     "permissions": permissions,
+                    "is_private": is_private,
                 }
 
             return {
@@ -83,6 +85,7 @@ class ForgejoBackend(GiteaBackend):
                 "message": "Connection successful",
                 "repo_name": data.get("full_name"),
                 "permissions": permissions,
+                "is_private": is_private,
             }
 
         except Exception as e:
@@ -98,4 +101,5 @@ class ForgejoBackend(GiteaBackend):
                 "message": message,
                 "repo_name": None,
                 "permissions": None,
+                "is_private": None,
             }

+ 4 - 0
backend/app/services/git_providers/github.py

@@ -80,6 +80,7 @@ class GitHubBackend(GitProviderBackend):
 
             data = response.json()
             permissions = data.get("permissions", {})
+            is_private = bool(data.get("private", False))
 
             if not permissions.get("push", False):
                 return {
@@ -87,6 +88,7 @@ class GitHubBackend(GitProviderBackend):
                     "message": "Token does not have push permission to this repository",
                     "repo_name": data.get("full_name"),
                     "permissions": permissions,
+                    "is_private": is_private,
                 }
 
             return {
@@ -94,6 +96,7 @@ class GitHubBackend(GitProviderBackend):
                 "message": "Connection successful",
                 "repo_name": data.get("full_name"),
                 "permissions": permissions,
+                "is_private": is_private,
             }
 
         except Exception as e:
@@ -109,6 +112,7 @@ class GitHubBackend(GitProviderBackend):
                 "message": message,
                 "repo_name": None,
                 "permissions": None,
+                "is_private": None,
             }
 
     async def push_files(

+ 9 - 0
backend/app/services/git_providers/gitlab.py

@@ -83,12 +83,19 @@ class GitLabBackend(GitProviderBackend):
             group_level = (perms.get("group_access") or {}).get("access_level", 0)
             effective = max(project_level, group_level)
 
+            # GitLab uses visibility="private" / "internal" / "public". Both
+            # "internal" (signed-in users) and "public" are non-private for
+            # the purposes of this safety check.
+            visibility = (data.get("visibility") or "").lower()
+            is_private = visibility == "private"
+
             if effective < 30:  # Developer = 30, Maintainer = 40, Owner = 50
                 return {
                     "success": False,
                     "message": "Token requires Developer access or higher to push",
                     "repo_name": data.get("name_with_namespace"),
                     "permissions": perms,
+                    "is_private": is_private,
                 }
 
             return {
@@ -96,6 +103,7 @@ class GitLabBackend(GitProviderBackend):
                 "message": "Connection successful",
                 "repo_name": data.get("name_with_namespace"),
                 "permissions": perms,
+                "is_private": is_private,
             }
         except Exception as e:
             logger.error("GitLab connection test failed: %s", e)
@@ -104,6 +112,7 @@ class GitLabBackend(GitProviderBackend):
                 "message": f"Connection failed: {type(e).__name__}",
                 "repo_name": None,
                 "permissions": None,
+                "is_private": None,
             }
 
     async def push_files(

+ 45 - 0
backend/app/services/github_backup.py

@@ -141,6 +141,51 @@ class GitHubBackupService:
                 if not config.enabled:
                     return {"success": False, "message": "Backup is disabled", "log_id": None}
 
+                # Defense in depth: re-verify the repo is private before each
+                # push. The save endpoint already enforces this on every config
+                # change, but a user can flip a repo from private to public in
+                # GitHub's UI between configuration and the next scheduled run.
+                test_result = await self.test_connection(
+                    config.repository_url, config.access_token, provider=config.provider
+                )
+                if not test_result.get("success") or test_result.get("is_private") is not True:
+                    visibility_note = (
+                        "the target repository is no longer private"
+                        if test_result.get("is_private") is False
+                        else "could not confirm the target repository is private"
+                    )
+                    abort_message = (
+                        f"Backup aborted: {visibility_note}. Bambuddy backups carry credentials "
+                        "and are refused for any non-private target. Make the repository private "
+                        "to resume scheduled backups."
+                    )
+                    log = GitHubBackupLog(
+                        config_id=config_id,
+                        status="failed",
+                        trigger=trigger,
+                        completed_at=datetime.now(timezone.utc),
+                        error_message=abort_message,
+                    )
+                    db.add(log)
+                    config.last_backup_at = datetime.now(timezone.utc)
+                    config.last_backup_status = "failed"
+                    config.last_backup_message = abort_message
+                    if config.schedule_enabled:
+                        config.next_scheduled_run = self.calculate_next_run(config.schedule_type)
+                    await db.commit()
+                    await db.refresh(log)
+                    logger.warning(
+                        "Backup aborted for config %s: repo not private (is_private=%r, success=%r)",
+                        config_id,
+                        test_result.get("is_private"),
+                        test_result.get("success"),
+                    )
+                    return {
+                        "success": False,
+                        "message": abort_message,
+                        "log_id": log.id,
+                    }
+
                 # Create log entry
                 log = GitHubBackupLog(config_id=config_id, status="running", trigger=trigger)
                 db.add(log)

+ 216 - 0
backend/tests/integration/test_github_backup_api.py

@@ -1,9 +1,36 @@
 """Integration tests for GitHub Backup API endpoints."""
 
+from unittest.mock import AsyncMock, patch
+
 import pytest
 from httpx import AsyncClient
 
 
+@pytest.fixture(autouse=True)
+def _mock_private_repo_check():
+    """Default mock: test_connection returns success + confirmed private.
+
+    POST /config and PATCH /config now refuse to save when the target repo
+    isn't confirmed private (Bambuddy backups carry credentials — see
+    `_enforce_private_repo` in github_backup.py routes). The default mock
+    here keeps the existing test suite green; tests that need to exercise
+    the public / unknown-visibility branches override this fixture inline.
+    """
+    with patch(
+        "backend.app.services.github_backup.github_backup_service.test_connection",
+        new=AsyncMock(
+            return_value={
+                "success": True,
+                "message": "Connection successful",
+                "repo_name": "test/repo",
+                "permissions": {"push": True},
+                "is_private": True,
+            }
+        ),
+    ) as m:
+        yield m
+
+
 class TestGitHubBackupConfigAPI:
     """Integration tests for /api/v1/github-backup endpoints."""
 
@@ -234,6 +261,195 @@ class TestGitHubBackupConfigAPI:
         assert response.status_code == 404
 
 
+class TestGitHubBackupPrivateRepoGuard:
+    """Refuse to save a config when the target repository is not private.
+
+    Bambuddy backups contain MQTT credentials, HA/Prometheus tokens, the
+    Bambu Cloud email, and printer access codes via K-profiles — they must
+    never be pushed to a public or internal-visibility repository.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_config_rejects_public_repo(self, async_client: AsyncClient):
+        """POST /config returns 400 when the connection test reports is_private=False."""
+        with patch(
+            "backend.app.services.github_backup.github_backup_service.test_connection",
+            new=AsyncMock(
+                return_value={
+                    "success": True,
+                    "message": "Connection successful",
+                    "repo_name": "test/public-repo",
+                    "permissions": {"push": True},
+                    "is_private": False,
+                }
+            ),
+        ):
+            response = await async_client.post(
+                "/api/v1/github-backup/config",
+                json={
+                    "repository_url": "https://github.com/test/public-repo",
+                    "access_token": "ghp_token",
+                    "branch": "main",
+                    "schedule_enabled": False,
+                    "schedule_type": "daily",
+                    "backup_kprofiles": True,
+                    "backup_cloud_profiles": True,
+                    "backup_settings": True,
+                    "enabled": True,
+                },
+            )
+
+        assert response.status_code == 400
+        assert "not private" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_config_rejects_unknown_visibility(self, async_client: AsyncClient):
+        """POST /config returns 400 when is_private cannot be determined (None)."""
+        with patch(
+            "backend.app.services.github_backup.github_backup_service.test_connection",
+            new=AsyncMock(
+                return_value={
+                    "success": True,
+                    "message": "Connection successful",
+                    "repo_name": "test/repo",
+                    "permissions": {"push": True},
+                    "is_private": None,
+                }
+            ),
+        ):
+            response = await async_client.post(
+                "/api/v1/github-backup/config",
+                json={
+                    "repository_url": "https://github.com/test/repo",
+                    "access_token": "ghp_token",
+                    "branch": "main",
+                    "schedule_enabled": False,
+                    "schedule_type": "daily",
+                    "backup_kprofiles": True,
+                    "backup_cloud_profiles": True,
+                    "backup_settings": True,
+                    "enabled": True,
+                },
+            )
+
+        assert response.status_code == 400
+        assert "could not confirm" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_config_rejects_failed_connection(self, async_client: AsyncClient):
+        """POST /config returns 400 when the connection test itself fails."""
+        with patch(
+            "backend.app.services.github_backup.github_backup_service.test_connection",
+            new=AsyncMock(
+                return_value={
+                    "success": False,
+                    "message": "Invalid access token",
+                    "repo_name": None,
+                    "permissions": None,
+                    "is_private": None,
+                }
+            ),
+        ):
+            response = await async_client.post(
+                "/api/v1/github-backup/config",
+                json={
+                    "repository_url": "https://github.com/test/repo",
+                    "access_token": "bad-token",
+                    "branch": "main",
+                    "schedule_enabled": False,
+                    "schedule_type": "daily",
+                    "backup_kprofiles": True,
+                    "backup_cloud_profiles": True,
+                    "backup_settings": True,
+                    "enabled": True,
+                },
+            )
+
+        assert response.status_code == 400
+        assert "invalid access token" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_rejects_url_change_to_public_repo(self, async_client: AsyncClient):
+        """Changing the repository_url on an existing config re-checks privacy."""
+        # Initial create succeeds via the default autouse mock (private).
+        await async_client.post(
+            "/api/v1/github-backup/config",
+            json={
+                "repository_url": "https://github.com/test/private-repo",
+                "access_token": "ghp_token",
+                "branch": "main",
+                "schedule_enabled": False,
+                "schedule_type": "daily",
+                "backup_kprofiles": True,
+                "backup_cloud_profiles": True,
+                "backup_settings": True,
+                "enabled": True,
+            },
+        )
+
+        # Now try to switch to a public repo — must be rejected.
+        with patch(
+            "backend.app.services.github_backup.github_backup_service.test_connection",
+            new=AsyncMock(
+                return_value={
+                    "success": True,
+                    "message": "Connection successful",
+                    "repo_name": "test/public-repo",
+                    "permissions": {"push": True},
+                    "is_private": False,
+                }
+            ),
+        ):
+            response = await async_client.patch(
+                "/api/v1/github-backup/config",
+                json={"repository_url": "https://github.com/test/public-repo"},
+            )
+
+        assert response.status_code == 400
+        assert "not private" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_skips_check_for_unrelated_fields(self, async_client: AsyncClient):
+        """PATCHing a non-target field (e.g. schedule) does NOT re-run the test.
+
+        Without this, every benign toggle would trigger a live API call.
+        """
+        await async_client.post(
+            "/api/v1/github-backup/config",
+            json={
+                "repository_url": "https://github.com/test/private-repo",
+                "access_token": "ghp_token",
+                "branch": "main",
+                "schedule_enabled": False,
+                "schedule_type": "daily",
+                "backup_kprofiles": True,
+                "backup_cloud_profiles": True,
+                "backup_settings": True,
+                "enabled": True,
+            },
+        )
+
+        # Replace the mock with one that would fail if called — proves the
+        # PATCH didn't hit test_connection for a schedule-only change.
+        mock = AsyncMock(side_effect=AssertionError("test_connection should not be called"))
+        with patch(
+            "backend.app.services.github_backup.github_backup_service.test_connection",
+            new=mock,
+        ):
+            response = await async_client.patch(
+                "/api/v1/github-backup/config",
+                json={"schedule_enabled": True},
+            )
+
+        assert response.status_code == 200
+        mock.assert_not_called()
+
+
 class TestGitHubBackupStatusAPI:
     """Integration tests for /api/v1/github-backup/status endpoint."""
 

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

@@ -2192,6 +2192,9 @@ export interface GitHubTestConnectionResponse {
   message: string;
   repo_name: string | null;
   permissions: Record<string, boolean> | null;
+  // true = confirmed private, false = confirmed public/internal,
+  // null = could not determine. Backend rejects save unless true.
+  is_private: boolean | null;
 }
 
 export interface GitHubBackupTriggerResponse {

+ 78 - 11
frontend/src/components/GitHubBackupSettings.tsx

@@ -248,7 +248,17 @@ export function GitHubBackupSettings() {
 
   // Test connection state
   const [testLoading, setTestLoading] = useState(false);
-  const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
+  const [testResult, setTestResult] = useState<{
+    success: boolean;
+    message: string;
+    isPrivate: boolean | null;
+  } | null>(null);
+  // Inline save-error banner — backend rejection messages (e.g. the
+  // "repository is not private" guard) are far too long for a toast.
+  // Cleared on success, on the next save attempt, and when the user starts
+  // editing the repo URL / token / provider so the banner doesn't persist
+  // after the user has already addressed the cause.
+  const [saveError, setSaveError] = useState<string | null>(null);
 
   // Auto-save debounce
   const settingsAutoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -388,6 +398,7 @@ export function GitHubBackupSettings() {
           access_token: accessToken,
         });
         setAccessToken(''); // Clear after save
+        setSaveError(null);
         showToast(t('backup.tokenUpdated'));
         lastTokenScheduledForSaveRef.current = '';
       } else {
@@ -396,13 +407,14 @@ export function GitHubBackupSettings() {
           autoSaveState,
           lastSavedAutosaveStateRef.current
         ));
+        setSaveError(null);
         showToast(t('backup.settingsSaved'));
       }
       lastSavedAutosaveStateRef.current = autoSaveState;
       queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
       queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
     } catch (error) {
-      showToast(t('backup.failedToSave', { message: (error as Error).message }), 'error');
+      setSaveError((error as Error).message);
     }
   }, [config, accessToken, autoSaveState, queryClient, showToast, t]);
 
@@ -459,6 +471,9 @@ export function GitHubBackupSettings() {
   // Mutations
   const saveConfigMutation = useMutation({
     mutationFn: (data: GitHubBackupConfigCreate) => api.saveGitHubBackupConfig(data),
+    onMutate: () => {
+      setSaveError(null);
+    },
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
       queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
@@ -467,7 +482,7 @@ export function GitHubBackupSettings() {
       setIsInitialized(true);
     },
     onError: (error: Error) => {
-      showToast(t('backup.failedToSave', { message: error.message }), 'error');
+      setSaveError(error.message);
     },
   });
 
@@ -523,9 +538,13 @@ export function GitHubBackupSettings() {
         setTestLoading(false);
         return;
       }
-      setTestResult({ success: result.success, message: result.message });
+      setTestResult({
+        success: result.success,
+        message: result.message,
+        isPrivate: result.is_private,
+      });
     } catch (error) {
-      setTestResult({ success: false, message: (error as Error).message });
+      setTestResult({ success: false, message: (error as Error).message, isPrivate: null });
     } finally {
       setTestLoading(false);
     }
@@ -600,7 +619,7 @@ export function GitHubBackupSettings() {
               <select
                 id="git-provider-select"
                 value={provider}
-                onChange={(e) => { setProvider(e.target.value as GitProviderType); setTestResult(null); }}
+                onChange={(e) => { setProvider(e.target.value as GitProviderType); setTestResult(null); setSaveError(null); }}
                 className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
               >
                 <option value="github">{t('backup.providerGitHub')}</option>
@@ -618,7 +637,7 @@ export function GitHubBackupSettings() {
                   <input
                     type="text"
                     value={repoUrl}
-                    onChange={(e) => { setRepoUrl(e.target.value); setTestResult(null); }}
+                    onChange={(e) => { setRepoUrl(e.target.value); setTestResult(null); setSaveError(null); }}
                     placeholder={t(PROVIDER_REPO_URL_I18N_KEY[provider])}
                     className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                   />
@@ -644,7 +663,7 @@ export function GitHubBackupSettings() {
                   <input
                     type="password"
                     value={accessToken}
-                    onChange={(e) => { setAccessToken(e.target.value); setTestResult(null); }}
+                    onChange={(e) => { setAccessToken(e.target.value); setTestResult(null); setSaveError(null); }}
                     placeholder={config?.has_token ? t('backup.enterNewToken') : PROVIDER_TOKEN_PLACEHOLDER[provider]}
                     className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                   />
@@ -802,11 +821,59 @@ export function GitHubBackupSettings() {
                 </div>
               )}
 
+              {/* Save error — inline banner. Keeps long backend rejection
+                  messages (e.g. the "repository is not private" guard)
+                  readable instead of clipped to a toast. */}
+              {saveError && (
+                <div className="text-sm text-red-400 bg-red-500/10 border border-red-500/30 rounded p-3 flex items-start gap-2">
+                  <XCircle className="w-4 h-4 mt-0.5 shrink-0" />
+                  <div className="flex-1 leading-relaxed whitespace-pre-wrap break-words">{saveError}</div>
+                  <button
+                    type="button"
+                    onClick={() => setSaveError(null)}
+                    className="text-bambu-gray hover:text-white shrink-0"
+                    aria-label={t('common.dismiss', 'Dismiss')}
+                  >
+                    <XCircle className="w-4 h-4" />
+                  </button>
+                </div>
+              )}
+
               {/* Test result */}
               {testResult && (
-                <div className={`text-sm flex items-center gap-1 ${testResult.success ? 'text-green-400' : 'text-red-400'}`}>
-                  {testResult.success ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
-                  {testResult.message}
+                <div className="space-y-1.5">
+                  <div className={`text-sm flex items-center gap-1 ${testResult.success ? 'text-green-400' : 'text-red-400'}`}>
+                    {testResult.success ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
+                    {testResult.message}
+                  </div>
+                  {testResult.success && testResult.isPrivate === true && (
+                    <div className="text-xs flex items-center gap-1 text-green-400">
+                      <CheckCircle className="w-3.5 h-3.5" />
+                      {t('backup.repoIsPrivate', 'Repository is private — safe to back up to.')}
+                    </div>
+                  )}
+                  {testResult.success && testResult.isPrivate === false && (
+                    <div className="text-xs text-red-400 bg-red-500/10 border border-red-500/30 rounded p-2 flex items-start gap-1.5">
+                      <XCircle className="w-4 h-4 mt-0.5 shrink-0" />
+                      <span>
+                        {t(
+                          'backup.repoIsPublicWarning',
+                          'Repository is PUBLIC. Bambuddy backups include MQTT credentials, Home Assistant tokens, Prometheus tokens, your Bambu Cloud email, and printer access codes via K-profiles. Saving is blocked until you make the repository private in your provider\'s settings.',
+                        )}
+                      </span>
+                    </div>
+                  )}
+                  {testResult.success && testResult.isPrivate === null && (
+                    <div className="text-xs text-yellow-400 bg-yellow-500/10 border border-yellow-500/30 rounded p-2 flex items-start gap-1.5">
+                      <XCircle className="w-4 h-4 mt-0.5 shrink-0" />
+                      <span>
+                        {t(
+                          'backup.repoVisibilityUnknown',
+                          'Could not determine repository visibility. Bambuddy refuses to back up to anything not confirmed private; saving will be blocked.',
+                        )}
+                      </span>
+                    </div>
+                  )}
                 </div>
               )}
 

+ 3 - 0
frontend/src/i18n/locales/de.ts

@@ -3883,6 +3883,9 @@ export default {
     cloudLoginRequired: 'Bambu Cloud Login erforderlich. Melden Sie sich unter Profile → Cloud-Profile an, um GitHub-Backup zu aktivieren.',
     cloudLoginRequiredShort: 'Cloud-Login erforderlich',
     githubDescription: 'Synchronisieren Sie Ihre Profile automatisch mit einem privaten GitHub-Repository für Backup und Versionsverlauf.',
+    repoIsPrivate: 'Repository ist privat — Sicherung möglich.',
+    repoIsPublicWarning: 'Das Repository ist ÖFFENTLICH. Bambuddy-Backups enthalten MQTT-Zugangsdaten, Home-Assistant-Tokens, Prometheus-Tokens, Ihre Bambu-Cloud-E-Mail-Adresse und über K-Profile auch Drucker-Zugangscodes. Speichern ist blockiert, bis Sie das Repository in den Einstellungen Ihres Anbieters auf privat stellen.',
+    repoVisibilityUnknown: 'Die Sichtbarkeit des Repositories konnte nicht bestimmt werden. Bambuddy sichert nur in Repositories, die nachweislich privat sind; Speichern wird blockiert.',
     repositoryUrl: 'Repository-URL',
     repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
 	repoUrlPlaceholderGitea: 'https://gitea.example.com/username/repo-name',

+ 3 - 0
frontend/src/i18n/locales/en.ts

@@ -3895,6 +3895,9 @@ export default {
     cloudLoginRequired: 'Bambu Cloud login required. Sign in under Profiles → Cloud Profiles to enable GitHub backup.',
     cloudLoginRequiredShort: 'Cloud login required',
     githubDescription: 'Automatically sync your profiles to a private GitHub repository for backup and version history.',
+    repoIsPrivate: 'Repository is private — safe to back up to.',
+    repoIsPublicWarning: 'Repository is PUBLIC. Bambuddy backups include MQTT credentials, Home Assistant tokens, Prometheus tokens, your Bambu Cloud email, and printer access codes via K-profiles. Saving is blocked until you make the repository private in your provider\'s settings.',
+    repoVisibilityUnknown: 'Could not determine repository visibility. Bambuddy refuses to back up to anything not confirmed private; saving will be blocked.',
     repositoryUrl: 'Repository URL',
     repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
 	repoUrlPlaceholderGitea: 'https://gitea.example.com/username/repo-name',

+ 3 - 0
frontend/src/i18n/locales/fr.ts

@@ -3872,6 +3872,9 @@ export default {
     cloudLoginRequired: 'Connexion Bambu Cloud requise. Connectez-vous sous Profils → Profils Cloud pour activer la sauvegarde GitHub.',
     cloudLoginRequiredShort: 'Connexion Cloud requise',
     githubDescription: 'Synchronisez automatiquement vos profils vers un dépôt GitHub privé pour la sauvegarde et l\'historique des versions.',
+    repoIsPrivate: 'Le dépôt est privé — la sauvegarde peut s\'y faire en toute sécurité.',
+    repoIsPublicWarning: 'Le dépôt est PUBLIC. Les sauvegardes Bambuddy contiennent les identifiants MQTT, les jetons Home Assistant, les jetons Prometheus, votre adresse Bambu Cloud, et les codes d\'accès des imprimantes via les K-profils. L\'enregistrement est bloqué tant que le dépôt n\'est pas passé en privé dans les paramètres de votre hébergeur.',
+    repoVisibilityUnknown: 'Impossible de déterminer la visibilité du dépôt. Bambuddy refuse toute sauvegarde vers un dépôt non confirmé privé ; l\'enregistrement sera bloqué.',
     repositoryUrl: 'URL du dépôt',
     repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
 	repoUrlPlaceholderGitea: 'https://gitea.example.com/username/repo-name',

+ 3 - 0
frontend/src/i18n/locales/it.ts

@@ -3871,6 +3871,9 @@ export default {
     cloudLoginRequired: 'Accesso Bambu Cloud richiesto. Accedi in Profili → Profili Cloud per abilitare il backup GitHub.',
     cloudLoginRequiredShort: 'Accesso Cloud richiesto',
     githubDescription: 'Sincronizza automaticamente i tuoi profili con un repository GitHub privato per backup e cronologia delle versioni.',
+    repoIsPrivate: 'Il repository è privato — è sicuro effettuare il backup.',
+    repoIsPublicWarning: 'Il repository è PUBBLICO. I backup di Bambuddy includono credenziali MQTT, token di Home Assistant, token di Prometheus, la tua e-mail Bambu Cloud e i codici di accesso alle stampanti tramite K-profile. Il salvataggio è bloccato finché non rendi privato il repository nelle impostazioni del tuo provider.',
+    repoVisibilityUnknown: 'Impossibile determinare la visibilità del repository. Bambuddy rifiuta di eseguire backup su qualsiasi target non confermato come privato; il salvataggio sarà bloccato.',
     repositoryUrl: 'URL del repository',
     repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
 	repoUrlPlaceholderGitea: 'https://gitea.example.com/username/repo-name',

+ 3 - 0
frontend/src/i18n/locales/ja.ts

@@ -3883,6 +3883,9 @@ export default {
     cloudLoginRequired: 'Bambu Cloudログインが必要です。GitHubバックアップを有効にするには、プロファイル → クラウドプロファイルからサインインしてください。',
     cloudLoginRequiredShort: 'Cloudログインが必要',
     githubDescription: 'プロファイルをプライベートGitHubリポジトリに自動的に同期し、バックアップとバージョン履歴を保持します。',
+    repoIsPrivate: 'リポジトリはプライベートです — バックアップしても安全です。',
+    repoIsPublicWarning: 'リポジトリが公開(PUBLIC)です。Bambuddyのバックアップには MQTT 認証情報、Home Assistant トークン、Prometheus トークン、Bambu Cloud のメールアドレス、K-プロファイル経由のプリンタアクセスコードが含まれます。プロバイダー側でリポジトリをプライベートに変更するまで保存はブロックされます。',
+    repoVisibilityUnknown: 'リポジトリの公開設定を確認できませんでした。Bambuddy はプライベートと確認できないリポジトリへのバックアップを拒否します。保存はブロックされます。',
     repositoryUrl: 'リポジトリURL',
     repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
 	repoUrlPlaceholderGitea: 'https://gitea.example.com/username/repo-name',

+ 3 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3871,6 +3871,9 @@ export default {
     cloudLoginRequired: 'Login no Bambu Cloud necessário. Entre em Perfis → Perfis Cloud para ativar o backup GitHub.',
     cloudLoginRequiredShort: 'Login Cloud necessário',
     githubDescription: 'Sincronize automaticamente seus perfis com um repositório GitHub privado para backup e histórico de versões.',
+    repoIsPrivate: 'O repositório é privado — seguro para backup.',
+    repoIsPublicWarning: 'O repositório está PÚBLICO. Os backups do Bambuddy incluem credenciais MQTT, tokens do Home Assistant, tokens do Prometheus, seu e-mail do Bambu Cloud e os códigos de acesso das impressoras via K-profiles. Salvar está bloqueado até que você torne o repositório privado nas configurações do seu provedor.',
+    repoVisibilityUnknown: 'Não foi possível determinar a visibilidade do repositório. O Bambuddy se recusa a fazer backup em qualquer destino não confirmado como privado; salvar será bloqueado.',
     repositoryUrl: 'URL do repositório',
     repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
 	repoUrlPlaceholderGitea: 'https://gitea.example.com/username/repo-name',

+ 3 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3871,6 +3871,9 @@ export default {
     cloudLoginRequired: '需要登录 Bambu Cloud。请在 配置文件 → 云配置文件 中登录以启用 GitHub 备份。',
     cloudLoginRequiredShort: '需要Cloud登录',
     githubDescription: '自动将您的配置文件同步到私有 GitHub 仓库以进行备份和版本历史记录。',
+    repoIsPrivate: '仓库为私有 — 可以安全备份。',
+    repoIsPublicWarning: '仓库为公开(PUBLIC)。Bambuddy 备份包含 MQTT 凭据、Home Assistant 令牌、Prometheus 令牌、您的 Bambu Cloud 邮箱以及通过 K-profile 暴露的打印机访问代码。在您于提供商设置中将仓库改为私有之前,保存将被阻止。',
+    repoVisibilityUnknown: '无法确定仓库的可见性。Bambuddy 拒绝向任何未确认为私有的目标进行备份;保存将被阻止。',
     repositoryUrl: '仓库 URL',
     repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
 	repoUrlPlaceholderGitea: 'https://gitea.example.com/username/repo-name',

+ 3 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3871,6 +3871,9 @@ export default {
     cloudLoginRequired: '需要登入 Bambu Cloud。請在 設定檔案 → 雲設定檔案 中登入以啟用 GitHub 備份。',
     cloudLoginRequiredShort: '需要雲端登入',
     githubDescription: '自動將您的設定檔案同步到私有 GitHub 倉庫以進行備份和版本歷史紀錄。',
+    repoIsPrivate: '儲存庫為私有 — 可安全備份。',
+    repoIsPublicWarning: '儲存庫為公開(PUBLIC)。Bambuddy 備份包含 MQTT 認證、Home Assistant 權杖、Prometheus 權杖、您的 Bambu Cloud 電子郵件,以及透過 K-profile 暴露的印表機存取碼。在您於服務商設定中將儲存庫改為私有之前,儲存將被阻擋。',
+    repoVisibilityUnknown: '無法確認儲存庫的可見性。Bambuddy 拒絕向任何未確認為私有的目標進行備份;儲存將被阻擋。',
     repositoryUrl: '倉庫 URL',
     repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
 	repoUrlPlaceholderGitea: 'https://gitea.example.com/username/repo-name',

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


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-5ID0OvMM.js"></script>
+    <script type="module" crossorigin src="/assets/index-DxQAbwU0.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-KYwGxnG9.css">
   </head>
   <body>

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