فهرست منبع

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 هفته پیش
والد
کامیت
48a7024b96

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 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"])
 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:
 def _config_to_response(config: GitHubBackupConfig) -> dict:
     """Convert config model to response dict."""
     """Convert config model to response dict."""
     return {
     return {
@@ -79,7 +112,16 @@ async def save_config(
     """Create or update GitHub backup configuration.
     """Create or update GitHub backup configuration.
 
 
     Only one configuration is supported. If one exists, it will be updated.
     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
     # Check for existing config
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     config = result.scalar_one_or_none()
     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.",
                 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():
     for key, value in update_dict.items():
         if key in ("schedule_type", "provider") and value is not None:
         if key in ("schedule_type", "provider") and value is not None:
             setattr(config, key, value.value)
             setattr(config, key, value.value)

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

@@ -176,6 +176,11 @@ class GitHubTestConnectionResponse(BaseModel):
     message: str
     message: str
     repo_name: str | None = None
     repo_name: str | None = None
     permissions: dict | 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):
 class GitHubBackupTriggerResponse(BaseModel):

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

@@ -69,6 +69,7 @@ class ForgejoBackend(GiteaBackend):
 
 
             data = repo_resp.json()
             data = repo_resp.json()
             permissions = data.get("permissions", {})
             permissions = data.get("permissions", {})
+            is_private = bool(data.get("private", False))
 
 
             if not permissions.get("push", False):
             if not permissions.get("push", False):
                 return {
                 return {
@@ -76,6 +77,7 @@ class ForgejoBackend(GiteaBackend):
                     "message": "Token does not have push permission to this repository",
                     "message": "Token does not have push permission to this repository",
                     "repo_name": data.get("full_name"),
                     "repo_name": data.get("full_name"),
                     "permissions": permissions,
                     "permissions": permissions,
+                    "is_private": is_private,
                 }
                 }
 
 
             return {
             return {
@@ -83,6 +85,7 @@ class ForgejoBackend(GiteaBackend):
                 "message": "Connection successful",
                 "message": "Connection successful",
                 "repo_name": data.get("full_name"),
                 "repo_name": data.get("full_name"),
                 "permissions": permissions,
                 "permissions": permissions,
+                "is_private": is_private,
             }
             }
 
 
         except Exception as e:
         except Exception as e:
@@ -98,4 +101,5 @@ class ForgejoBackend(GiteaBackend):
                 "message": message,
                 "message": message,
                 "repo_name": None,
                 "repo_name": None,
                 "permissions": 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()
             data = response.json()
             permissions = data.get("permissions", {})
             permissions = data.get("permissions", {})
+            is_private = bool(data.get("private", False))
 
 
             if not permissions.get("push", False):
             if not permissions.get("push", False):
                 return {
                 return {
@@ -87,6 +88,7 @@ class GitHubBackend(GitProviderBackend):
                     "message": "Token does not have push permission to this repository",
                     "message": "Token does not have push permission to this repository",
                     "repo_name": data.get("full_name"),
                     "repo_name": data.get("full_name"),
                     "permissions": permissions,
                     "permissions": permissions,
+                    "is_private": is_private,
                 }
                 }
 
 
             return {
             return {
@@ -94,6 +96,7 @@ class GitHubBackend(GitProviderBackend):
                 "message": "Connection successful",
                 "message": "Connection successful",
                 "repo_name": data.get("full_name"),
                 "repo_name": data.get("full_name"),
                 "permissions": permissions,
                 "permissions": permissions,
+                "is_private": is_private,
             }
             }
 
 
         except Exception as e:
         except Exception as e:
@@ -109,6 +112,7 @@ class GitHubBackend(GitProviderBackend):
                 "message": message,
                 "message": message,
                 "repo_name": None,
                 "repo_name": None,
                 "permissions": None,
                 "permissions": None,
+                "is_private": None,
             }
             }
 
 
     async def push_files(
     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)
             group_level = (perms.get("group_access") or {}).get("access_level", 0)
             effective = max(project_level, group_level)
             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
             if effective < 30:  # Developer = 30, Maintainer = 40, Owner = 50
                 return {
                 return {
                     "success": False,
                     "success": False,
                     "message": "Token requires Developer access or higher to push",
                     "message": "Token requires Developer access or higher to push",
                     "repo_name": data.get("name_with_namespace"),
                     "repo_name": data.get("name_with_namespace"),
                     "permissions": perms,
                     "permissions": perms,
+                    "is_private": is_private,
                 }
                 }
 
 
             return {
             return {
@@ -96,6 +103,7 @@ class GitLabBackend(GitProviderBackend):
                 "message": "Connection successful",
                 "message": "Connection successful",
                 "repo_name": data.get("name_with_namespace"),
                 "repo_name": data.get("name_with_namespace"),
                 "permissions": perms,
                 "permissions": perms,
+                "is_private": is_private,
             }
             }
         except Exception as e:
         except Exception as e:
             logger.error("GitLab connection test failed: %s", e)
             logger.error("GitLab connection test failed: %s", e)
@@ -104,6 +112,7 @@ class GitLabBackend(GitProviderBackend):
                 "message": f"Connection failed: {type(e).__name__}",
                 "message": f"Connection failed: {type(e).__name__}",
                 "repo_name": None,
                 "repo_name": None,
                 "permissions": None,
                 "permissions": None,
+                "is_private": None,
             }
             }
 
 
     async def push_files(
     async def push_files(

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

@@ -141,6 +141,51 @@ class GitHubBackupService:
                 if not config.enabled:
                 if not config.enabled:
                     return {"success": False, "message": "Backup is disabled", "log_id": None}
                     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
                 # Create log entry
                 log = GitHubBackupLog(config_id=config_id, status="running", trigger=trigger)
                 log = GitHubBackupLog(config_id=config_id, status="running", trigger=trigger)
                 db.add(log)
                 db.add(log)

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

@@ -1,9 +1,36 @@
 """Integration tests for GitHub Backup API endpoints."""
 """Integration tests for GitHub Backup API endpoints."""
 
 
+from unittest.mock import AsyncMock, patch
+
 import pytest
 import pytest
 from httpx import AsyncClient
 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:
 class TestGitHubBackupConfigAPI:
     """Integration tests for /api/v1/github-backup endpoints."""
     """Integration tests for /api/v1/github-backup endpoints."""
 
 
@@ -234,6 +261,195 @@ class TestGitHubBackupConfigAPI:
         assert response.status_code == 404
         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:
 class TestGitHubBackupStatusAPI:
     """Integration tests for /api/v1/github-backup/status endpoint."""
     """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;
   message: string;
   repo_name: string | null;
   repo_name: string | null;
   permissions: Record<string, boolean> | 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 {
 export interface GitHubBackupTriggerResponse {

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

@@ -248,7 +248,17 @@ export function GitHubBackupSettings() {
 
 
   // Test connection state
   // Test connection state
   const [testLoading, setTestLoading] = useState(false);
   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
   // Auto-save debounce
   const settingsAutoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
   const settingsAutoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -388,6 +398,7 @@ export function GitHubBackupSettings() {
           access_token: accessToken,
           access_token: accessToken,
         });
         });
         setAccessToken(''); // Clear after save
         setAccessToken(''); // Clear after save
+        setSaveError(null);
         showToast(t('backup.tokenUpdated'));
         showToast(t('backup.tokenUpdated'));
         lastTokenScheduledForSaveRef.current = '';
         lastTokenScheduledForSaveRef.current = '';
       } else {
       } else {
@@ -396,13 +407,14 @@ export function GitHubBackupSettings() {
           autoSaveState,
           autoSaveState,
           lastSavedAutosaveStateRef.current
           lastSavedAutosaveStateRef.current
         ));
         ));
+        setSaveError(null);
         showToast(t('backup.settingsSaved'));
         showToast(t('backup.settingsSaved'));
       }
       }
       lastSavedAutosaveStateRef.current = autoSaveState;
       lastSavedAutosaveStateRef.current = autoSaveState;
       queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
       queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
       queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
       queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
     } catch (error) {
     } catch (error) {
-      showToast(t('backup.failedToSave', { message: (error as Error).message }), 'error');
+      setSaveError((error as Error).message);
     }
     }
   }, [config, accessToken, autoSaveState, queryClient, showToast, t]);
   }, [config, accessToken, autoSaveState, queryClient, showToast, t]);
 
 
@@ -459,6 +471,9 @@ export function GitHubBackupSettings() {
   // Mutations
   // Mutations
   const saveConfigMutation = useMutation({
   const saveConfigMutation = useMutation({
     mutationFn: (data: GitHubBackupConfigCreate) => api.saveGitHubBackupConfig(data),
     mutationFn: (data: GitHubBackupConfigCreate) => api.saveGitHubBackupConfig(data),
+    onMutate: () => {
+      setSaveError(null);
+    },
     onSuccess: () => {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
       queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
       queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
       queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
@@ -467,7 +482,7 @@ export function GitHubBackupSettings() {
       setIsInitialized(true);
       setIsInitialized(true);
     },
     },
     onError: (error: Error) => {
     onError: (error: Error) => {
-      showToast(t('backup.failedToSave', { message: error.message }), 'error');
+      setSaveError(error.message);
     },
     },
   });
   });
 
 
@@ -523,9 +538,13 @@ export function GitHubBackupSettings() {
         setTestLoading(false);
         setTestLoading(false);
         return;
         return;
       }
       }
-      setTestResult({ success: result.success, message: result.message });
+      setTestResult({
+        success: result.success,
+        message: result.message,
+        isPrivate: result.is_private,
+      });
     } catch (error) {
     } catch (error) {
-      setTestResult({ success: false, message: (error as Error).message });
+      setTestResult({ success: false, message: (error as Error).message, isPrivate: null });
     } finally {
     } finally {
       setTestLoading(false);
       setTestLoading(false);
     }
     }
@@ -600,7 +619,7 @@ export function GitHubBackupSettings() {
               <select
               <select
                 id="git-provider-select"
                 id="git-provider-select"
                 value={provider}
                 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"
                 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>
                 <option value="github">{t('backup.providerGitHub')}</option>
@@ -618,7 +637,7 @@ export function GitHubBackupSettings() {
                   <input
                   <input
                     type="text"
                     type="text"
                     value={repoUrl}
                     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])}
                     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"
                     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
                   <input
                     type="password"
                     type="password"
                     value={accessToken}
                     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]}
                     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"
                     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>
                 </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 */}
               {/* Test result */}
               {testResult && (
               {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>
                 </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.',
     cloudLoginRequired: 'Bambu Cloud Login erforderlich. Melden Sie sich unter Profile → Cloud-Profile an, um GitHub-Backup zu aktivieren.',
     cloudLoginRequiredShort: 'Cloud-Login erforderlich',
     cloudLoginRequiredShort: 'Cloud-Login erforderlich',
     githubDescription: 'Synchronisieren Sie Ihre Profile automatisch mit einem privaten GitHub-Repository für Backup und Versionsverlauf.',
     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',
     repositoryUrl: 'Repository-URL',
     repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
     repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
 	repoUrlPlaceholderGitea: 'https://gitea.example.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.',
     cloudLoginRequired: 'Bambu Cloud login required. Sign in under Profiles → Cloud Profiles to enable GitHub backup.',
     cloudLoginRequiredShort: 'Cloud login required',
     cloudLoginRequiredShort: 'Cloud login required',
     githubDescription: 'Automatically sync your profiles to a private GitHub repository for backup and version history.',
     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',
     repositoryUrl: 'Repository URL',
     repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
     repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
 	repoUrlPlaceholderGitea: 'https://gitea.example.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.',
     cloudLoginRequired: 'Connexion Bambu Cloud requise. Connectez-vous sous Profils → Profils Cloud pour activer la sauvegarde GitHub.',
     cloudLoginRequiredShort: 'Connexion Cloud requise',
     cloudLoginRequiredShort: 'Connexion Cloud requise',
     githubDescription: 'Synchronisez automatiquement vos profils vers un dépôt GitHub privé pour la sauvegarde et l\'historique des versions.',
     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',
     repositoryUrl: 'URL du dépôt',
     repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
     repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
 	repoUrlPlaceholderGitea: 'https://gitea.example.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.',
     cloudLoginRequired: 'Accesso Bambu Cloud richiesto. Accedi in Profili → Profili Cloud per abilitare il backup GitHub.',
     cloudLoginRequiredShort: 'Accesso Cloud richiesto',
     cloudLoginRequiredShort: 'Accesso Cloud richiesto',
     githubDescription: 'Sincronizza automaticamente i tuoi profili con un repository GitHub privato per backup e cronologia delle versioni.',
     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',
     repositoryUrl: 'URL del repository',
     repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
     repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
 	repoUrlPlaceholderGitea: 'https://gitea.example.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バックアップを有効にするには、プロファイル → クラウドプロファイルからサインインしてください。',
     cloudLoginRequired: 'Bambu Cloudログインが必要です。GitHubバックアップを有効にするには、プロファイル → クラウドプロファイルからサインインしてください。',
     cloudLoginRequiredShort: 'Cloudログインが必要',
     cloudLoginRequiredShort: 'Cloudログインが必要',
     githubDescription: 'プロファイルをプライベートGitHubリポジトリに自動的に同期し、バックアップとバージョン履歴を保持します。',
     githubDescription: 'プロファイルをプライベートGitHubリポジトリに自動的に同期し、バックアップとバージョン履歴を保持します。',
+    repoIsPrivate: 'リポジトリはプライベートです — バックアップしても安全です。',
+    repoIsPublicWarning: 'リポジトリが公開(PUBLIC)です。Bambuddyのバックアップには MQTT 認証情報、Home Assistant トークン、Prometheus トークン、Bambu Cloud のメールアドレス、K-プロファイル経由のプリンタアクセスコードが含まれます。プロバイダー側でリポジトリをプライベートに変更するまで保存はブロックされます。',
+    repoVisibilityUnknown: 'リポジトリの公開設定を確認できませんでした。Bambuddy はプライベートと確認できないリポジトリへのバックアップを拒否します。保存はブロックされます。',
     repositoryUrl: 'リポジトリURL',
     repositoryUrl: 'リポジトリURL',
     repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
     repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
 	repoUrlPlaceholderGitea: 'https://gitea.example.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.',
     cloudLoginRequired: 'Login no Bambu Cloud necessário. Entre em Perfis → Perfis Cloud para ativar o backup GitHub.',
     cloudLoginRequiredShort: 'Login Cloud necessário',
     cloudLoginRequiredShort: 'Login Cloud necessário',
     githubDescription: 'Sincronize automaticamente seus perfis com um repositório GitHub privado para backup e histórico de versões.',
     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',
     repositoryUrl: 'URL do repositório',
     repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
     repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
 	repoUrlPlaceholderGitea: 'https://gitea.example.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 备份。',
     cloudLoginRequired: '需要登录 Bambu Cloud。请在 配置文件 → 云配置文件 中登录以启用 GitHub 备份。',
     cloudLoginRequiredShort: '需要Cloud登录',
     cloudLoginRequiredShort: '需要Cloud登录',
     githubDescription: '自动将您的配置文件同步到私有 GitHub 仓库以进行备份和版本历史记录。',
     githubDescription: '自动将您的配置文件同步到私有 GitHub 仓库以进行备份和版本历史记录。',
+    repoIsPrivate: '仓库为私有 — 可以安全备份。',
+    repoIsPublicWarning: '仓库为公开(PUBLIC)。Bambuddy 备份包含 MQTT 凭据、Home Assistant 令牌、Prometheus 令牌、您的 Bambu Cloud 邮箱以及通过 K-profile 暴露的打印机访问代码。在您于提供商设置中将仓库改为私有之前,保存将被阻止。',
+    repoVisibilityUnknown: '无法确定仓库的可见性。Bambuddy 拒绝向任何未确认为私有的目标进行备份;保存将被阻止。',
     repositoryUrl: '仓库 URL',
     repositoryUrl: '仓库 URL',
     repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
     repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
 	repoUrlPlaceholderGitea: 'https://gitea.example.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 備份。',
     cloudLoginRequired: '需要登入 Bambu Cloud。請在 設定檔案 → 雲設定檔案 中登入以啟用 GitHub 備份。',
     cloudLoginRequiredShort: '需要雲端登入',
     cloudLoginRequiredShort: '需要雲端登入',
     githubDescription: '自動將您的設定檔案同步到私有 GitHub 倉庫以進行備份和版本歷史紀錄。',
     githubDescription: '自動將您的設定檔案同步到私有 GitHub 倉庫以進行備份和版本歷史紀錄。',
+    repoIsPrivate: '儲存庫為私有 — 可安全備份。',
+    repoIsPublicWarning: '儲存庫為公開(PUBLIC)。Bambuddy 備份包含 MQTT 認證、Home Assistant 權杖、Prometheus 權杖、您的 Bambu Cloud 電子郵件,以及透過 K-profile 暴露的印表機存取碼。在您於服務商設定中將儲存庫改為私有之前,儲存將被阻擋。',
+    repoVisibilityUnknown: '無法確認儲存庫的可見性。Bambuddy 拒絕向任何未確認為私有的目標進行備份;儲存將被阻擋。',
     repositoryUrl: '倉庫 URL',
     repositoryUrl: '倉庫 URL',
     repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
     repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
 	repoUrlPlaceholderGitea: 'https://gitea.example.com/username/repo-name',
 	repoUrlPlaceholderGitea: 'https://gitea.example.com/username/repo-name',

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-DxQAbwU0.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <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">
     <link rel="stylesheet" crossorigin href="/assets/index-KYwGxnG9.css">
   </head>
   </head>
   <body>
   <body>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است