mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-01 11:47:46 +02:00
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.
429 lines
16 KiB
Python
429 lines
16 KiB
Python
"""API routes for GitHub profile backup."""
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy import delete, desc, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
|
from backend.app.core.database import get_db
|
|
from backend.app.core.permissions import Permission
|
|
from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
|
|
from backend.app.models.user import User
|
|
from backend.app.schemas.github_backup import (
|
|
GitHubBackupConfigCreate,
|
|
GitHubBackupConfigResponse,
|
|
GitHubBackupConfigUpdate,
|
|
GitHubBackupLogResponse,
|
|
GitHubBackupStatus,
|
|
GitHubBackupTriggerResponse,
|
|
GitHubTestConnectionResponse,
|
|
ProviderType,
|
|
)
|
|
from backend.app.services.github_backup import github_backup_service
|
|
|
|
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 {
|
|
"id": config.id,
|
|
"repository_url": config.repository_url,
|
|
"has_token": bool(config.access_token),
|
|
"branch": config.branch,
|
|
"provider": config.provider,
|
|
"allow_insecure_http": config.allow_insecure_http,
|
|
"schedule_enabled": config.schedule_enabled,
|
|
"schedule_type": config.schedule_type,
|
|
"backup_kprofiles": config.backup_kprofiles,
|
|
"backup_cloud_profiles": config.backup_cloud_profiles,
|
|
"backup_settings": config.backup_settings,
|
|
"backup_spools": config.backup_spools,
|
|
"backup_archives": config.backup_archives,
|
|
"enabled": config.enabled,
|
|
"last_backup_at": config.last_backup_at,
|
|
"last_backup_status": config.last_backup_status,
|
|
"last_backup_message": config.last_backup_message,
|
|
"last_backup_commit_sha": config.last_backup_commit_sha,
|
|
"next_scheduled_run": config.next_scheduled_run,
|
|
"created_at": config.created_at,
|
|
"updated_at": config.updated_at,
|
|
}
|
|
|
|
|
|
@router.get("/config", response_model=GitHubBackupConfigResponse | None)
|
|
async def get_config(
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
|
):
|
|
"""Get the current GitHub backup configuration."""
|
|
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
|
config = result.scalar_one_or_none()
|
|
|
|
if not config:
|
|
return None
|
|
|
|
return _config_to_response(config)
|
|
|
|
|
|
@router.post("/config", response_model=GitHubBackupConfigResponse)
|
|
async def save_config(
|
|
config_data: GitHubBackupConfigCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
|
):
|
|
"""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()
|
|
|
|
if config:
|
|
# Update existing
|
|
config.repository_url = config_data.repository_url
|
|
config.access_token = config_data.access_token
|
|
config.branch = config_data.branch
|
|
config.provider = config_data.provider.value
|
|
config.schedule_enabled = config_data.schedule_enabled
|
|
config.schedule_type = config_data.schedule_type.value
|
|
config.backup_kprofiles = config_data.backup_kprofiles
|
|
config.backup_cloud_profiles = config_data.backup_cloud_profiles
|
|
config.backup_settings = config_data.backup_settings
|
|
config.backup_spools = config_data.backup_spools
|
|
config.backup_archives = config_data.backup_archives
|
|
config.allow_insecure_http = config_data.allow_insecure_http
|
|
config.enabled = config_data.enabled
|
|
|
|
# Calculate next scheduled run if enabled
|
|
if config.schedule_enabled:
|
|
config.next_scheduled_run = github_backup_service.calculate_next_run(config.schedule_type)
|
|
else:
|
|
config.next_scheduled_run = None
|
|
|
|
logger.info("Updated GitHub backup config: %s", config.repository_url)
|
|
else:
|
|
# Create new
|
|
config = GitHubBackupConfig(
|
|
repository_url=config_data.repository_url,
|
|
access_token=config_data.access_token,
|
|
branch=config_data.branch,
|
|
provider=config_data.provider.value,
|
|
schedule_enabled=config_data.schedule_enabled,
|
|
schedule_type=config_data.schedule_type.value,
|
|
backup_kprofiles=config_data.backup_kprofiles,
|
|
backup_cloud_profiles=config_data.backup_cloud_profiles,
|
|
backup_settings=config_data.backup_settings,
|
|
backup_spools=config_data.backup_spools,
|
|
backup_archives=config_data.backup_archives,
|
|
allow_insecure_http=config_data.allow_insecure_http,
|
|
enabled=config_data.enabled,
|
|
)
|
|
|
|
if config.schedule_enabled:
|
|
config.next_scheduled_run = github_backup_service.calculate_next_run(config.schedule_type)
|
|
|
|
db.add(config)
|
|
logger.info("Created GitHub backup config: %s", config.repository_url)
|
|
|
|
await db.commit()
|
|
await db.refresh(config)
|
|
|
|
return _config_to_response(config)
|
|
|
|
|
|
@router.patch("/config", response_model=GitHubBackupConfigResponse)
|
|
async def update_config(
|
|
update_data: GitHubBackupConfigUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
|
):
|
|
"""Partially update GitHub backup configuration."""
|
|
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
|
config = result.scalar_one_or_none()
|
|
|
|
if not config:
|
|
raise HTTPException(status_code=404, detail="No configuration found")
|
|
|
|
update_dict = update_data.model_dump(exclude_unset=True)
|
|
|
|
# Validate HTTP URL restriction when the URL policy is being changed. This avoids blocking unrelated autosaves
|
|
# for legacy configs that already contain an HTTP URL.
|
|
if "repository_url" in update_dict or "allow_insecure_http" in update_dict:
|
|
url_to_check = update_dict.get("repository_url", config.repository_url)
|
|
effective_allow_http = update_dict.get("allow_insecure_http", config.allow_insecure_http)
|
|
if url_to_check and url_to_check.startswith("http://") and not effective_allow_http:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
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)
|
|
else:
|
|
setattr(config, key, value)
|
|
|
|
# Recalculate next scheduled run if schedule settings changed
|
|
if "schedule_enabled" in update_dict or "schedule_type" in update_dict:
|
|
if config.schedule_enabled:
|
|
config.next_scheduled_run = github_backup_service.calculate_next_run(config.schedule_type)
|
|
else:
|
|
config.next_scheduled_run = None
|
|
|
|
await db.commit()
|
|
await db.refresh(config)
|
|
|
|
logger.info("Updated GitHub backup config: %s", config.repository_url)
|
|
|
|
return _config_to_response(config)
|
|
|
|
|
|
@router.delete("/config")
|
|
async def delete_config(
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
|
):
|
|
"""Delete the GitHub backup configuration and all logs."""
|
|
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
|
config = result.scalar_one_or_none()
|
|
|
|
if not config:
|
|
raise HTTPException(status_code=404, detail="No configuration found")
|
|
|
|
await db.delete(config)
|
|
await db.commit()
|
|
|
|
logger.info("Deleted GitHub backup config")
|
|
|
|
return {"message": "Configuration deleted"}
|
|
|
|
|
|
@router.post("/test", response_model=GitHubTestConnectionResponse)
|
|
async def test_connection(
|
|
repo_url: str = Query(..., description="Repository URL"),
|
|
token: str = Query(..., description="Personal Access Token"),
|
|
provider: ProviderType = Query(default=ProviderType.GITHUB, description="Git provider key"),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
|
):
|
|
"""Test Git provider connection with provided credentials."""
|
|
result = await github_backup_service.test_connection(repo_url, token, provider=provider)
|
|
return GitHubTestConnectionResponse(**result)
|
|
|
|
|
|
@router.post("/test-stored", response_model=GitHubTestConnectionResponse)
|
|
async def test_stored_connection(
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
|
):
|
|
"""Test GitHub connection using stored configuration."""
|
|
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
|
config = result.scalar_one_or_none()
|
|
|
|
if not config:
|
|
raise HTTPException(status_code=404, detail="No configuration found")
|
|
|
|
if not config.access_token:
|
|
raise HTTPException(status_code=400, detail="No access token configured")
|
|
|
|
test_result = await github_backup_service.test_connection(
|
|
config.repository_url,
|
|
config.access_token,
|
|
provider=config.provider,
|
|
)
|
|
return GitHubTestConnectionResponse(**test_result)
|
|
|
|
|
|
@router.post("/run", response_model=GitHubBackupTriggerResponse)
|
|
async def trigger_backup(
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
|
):
|
|
"""Manually trigger a backup."""
|
|
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
|
config = result.scalar_one_or_none()
|
|
|
|
if not config:
|
|
raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
|
|
|
|
if not config.enabled:
|
|
raise HTTPException(status_code=400, detail="Backup is disabled")
|
|
|
|
backup_result = await github_backup_service.run_backup(config.id, trigger="manual")
|
|
|
|
return GitHubBackupTriggerResponse(**backup_result)
|
|
|
|
|
|
@router.get("/status", response_model=GitHubBackupStatus)
|
|
async def get_status(
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
|
):
|
|
"""Get current backup status."""
|
|
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
|
config = result.scalar_one_or_none()
|
|
|
|
if not config:
|
|
return GitHubBackupStatus(
|
|
configured=False,
|
|
enabled=False,
|
|
is_running=False,
|
|
progress=None,
|
|
last_backup_at=None,
|
|
last_backup_status=None,
|
|
next_scheduled_run=None,
|
|
)
|
|
|
|
return GitHubBackupStatus(
|
|
configured=True,
|
|
enabled=config.enabled,
|
|
is_running=github_backup_service.is_running,
|
|
progress=github_backup_service.progress,
|
|
last_backup_at=config.last_backup_at,
|
|
last_backup_status=config.last_backup_status,
|
|
next_scheduled_run=config.next_scheduled_run,
|
|
)
|
|
|
|
|
|
@router.get("/logs", response_model=list[GitHubBackupLogResponse])
|
|
async def get_logs(
|
|
limit: int = Query(default=50, ge=1, le=200),
|
|
offset: int = Query(default=0, ge=0),
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
|
):
|
|
"""Get backup logs."""
|
|
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
|
config = result.scalar_one_or_none()
|
|
|
|
if not config:
|
|
return []
|
|
|
|
logs_result = await db.execute(
|
|
select(GitHubBackupLog)
|
|
.where(GitHubBackupLog.config_id == config.id)
|
|
.order_by(desc(GitHubBackupLog.started_at))
|
|
.offset(offset)
|
|
.limit(limit)
|
|
)
|
|
logs = logs_result.scalars().all()
|
|
|
|
return [
|
|
GitHubBackupLogResponse(
|
|
id=log.id,
|
|
config_id=log.config_id,
|
|
started_at=log.started_at,
|
|
completed_at=log.completed_at,
|
|
status=log.status,
|
|
trigger=log.trigger,
|
|
commit_sha=log.commit_sha,
|
|
files_changed=log.files_changed,
|
|
error_message=log.error_message,
|
|
)
|
|
for log in logs
|
|
]
|
|
|
|
|
|
@router.delete("/logs")
|
|
async def clear_logs(
|
|
keep_last: int = Query(default=10, ge=0, le=100, description="Number of recent logs to keep"),
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
|
):
|
|
"""Clear backup logs, optionally keeping the most recent entries."""
|
|
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
|
config = result.scalar_one_or_none()
|
|
|
|
if not config:
|
|
return {"deleted": 0, "message": "No configuration found"}
|
|
|
|
if keep_last > 0:
|
|
# Get IDs to keep
|
|
keep_result = await db.execute(
|
|
select(GitHubBackupLog.id)
|
|
.where(GitHubBackupLog.config_id == config.id)
|
|
.order_by(desc(GitHubBackupLog.started_at))
|
|
.limit(keep_last)
|
|
)
|
|
keep_ids = [row[0] for row in keep_result.fetchall()]
|
|
|
|
if keep_ids:
|
|
delete_result = await db.execute(
|
|
delete(GitHubBackupLog).where(
|
|
GitHubBackupLog.config_id == config.id, GitHubBackupLog.id.not_in(keep_ids)
|
|
)
|
|
)
|
|
else:
|
|
delete_result = await db.execute(delete(GitHubBackupLog).where(GitHubBackupLog.config_id == config.id))
|
|
else:
|
|
delete_result = await db.execute(delete(GitHubBackupLog).where(GitHubBackupLog.config_id == config.id))
|
|
|
|
await db.commit()
|
|
|
|
deleted_count = delete_result.rowcount
|
|
logger.info("Deleted %s GitHub backup logs (kept %s)", deleted_count, keep_last)
|
|
|
|
return {"deleted": deleted_count, "message": f"Deleted {deleted_count} logs"}
|