Files
bambuddy/backend/app/schemas/github_backup.py
T
maziggy 48a7024b96 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.
2026-05-17 15:30:05 +02:00

194 lines
6.4 KiB
Python

"""Pydantic schemas for GitHub backup configuration."""
import re
from datetime import datetime
from pydantic import BaseModel, Field, model_validator
from backend.app.core.compat import StrEnum
class ScheduleType(StrEnum):
"""Backup schedule types."""
HOURLY = "hourly"
DAILY = "daily"
WEEKLY = "weekly"
class ProviderType(StrEnum):
"""Git hosting provider types."""
GITHUB = "github"
GITLAB = "gitlab"
GITEA = "gitea"
FORGEJO = "forgejo"
class GitHubBackupConfigCreate(BaseModel):
"""Schema for creating/updating GitHub backup config."""
repository_url: str = Field(..., min_length=1, max_length=500, description="Git repository URL")
access_token: str = Field(..., min_length=1, description="Personal Access Token")
branch: str = Field(default="main", max_length=100, description="Branch to push to")
provider: ProviderType = Field(default=ProviderType.GITHUB, description="Git hosting provider")
schedule_enabled: bool = Field(default=False, description="Enable scheduled backups")
schedule_type: ScheduleType = Field(default=ScheduleType.DAILY, description="Schedule frequency")
backup_kprofiles: bool = Field(default=True, description="Backup K-profiles")
backup_cloud_profiles: bool = Field(default=True, description="Backup Bambu Cloud profiles")
backup_settings: bool = Field(default=False, description="Backup app settings")
backup_spools: bool = Field(default=False, description="Backup spool inventory")
backup_archives: bool = Field(default=False, description="Backup print archive history")
allow_insecure_http: bool = Field(default=False, description="Allow HTTP (non-TLS) repository URLs")
enabled: bool = Field(default=True, description="Enable backup feature")
@model_validator(mode="after")
def validate_repo_url(self) -> "GitHubBackupConfigCreate":
url = self.repository_url.strip().rstrip("/")
self.repository_url = url
https_or_ssh = [
r"^https://[\w.-]+(:\d+)?/[\w.-]+(\/[\w.-]+)+(?:\.git)?/?$",
r"^git@[\w.-]+:[\w.-]+(\/[\w.-]+)+(?:\.git)?$",
]
http_pattern = r"^http://[\w.-]+(:\d+)?/[\w.-]+(\/[\w.-]+)+(?:\.git)?/?$"
if any(re.match(p, url) for p in https_or_ssh):
return self
if re.match(http_pattern, url):
if not self.allow_insecure_http:
raise ValueError(
"This URL uses HTTP instead of HTTPS. "
"Enable 'Allow insecure HTTP' if your instance does not use TLS."
)
return self
raise ValueError(
"Invalid Git repository URL. Expected: https://host/owner/repo, "
"http://host/owner/repo (with 'Allow insecure HTTP' enabled), or git@host:owner/repo"
)
class GitHubBackupConfigUpdate(BaseModel):
"""Schema for updating GitHub backup config (all fields optional)."""
repository_url: str | None = Field(default=None, max_length=500)
access_token: str | None = Field(default=None)
branch: str | None = Field(default=None, max_length=100)
provider: ProviderType | None = None
schedule_enabled: bool | None = None
schedule_type: ScheduleType | None = None
backup_kprofiles: bool | None = None
backup_cloud_profiles: bool | None = None
backup_settings: bool | None = None
backup_spools: bool | None = None
backup_archives: bool | None = None
allow_insecure_http: bool | None = None
enabled: bool | None = None
@model_validator(mode="after")
def validate_repo_url(self) -> "GitHubBackupConfigUpdate":
if self.repository_url is None:
return self
url = self.repository_url.strip().rstrip("/")
self.repository_url = url
valid_patterns = [
r"^https?://[\w.-]+(:\d+)?/[\w.-]+(\/[\w.-]+)+(?:\.git)?/?$",
r"^git@[\w.-]+:[\w.-]+(\/[\w.-]+)+(?:\.git)?$",
]
if not any(re.match(p, url) for p in valid_patterns):
raise ValueError(
"Invalid repository URL. Expected: https://host/owner/repo, "
"http://host/owner/repo, or git@host:owner/repo"
)
return self
class GitHubBackupConfigResponse(BaseModel):
"""Schema for GitHub backup config API response."""
id: int
repository_url: str
has_token: bool = Field(description="Whether an access token is configured")
branch: str
provider: str
allow_insecure_http: bool
schedule_enabled: bool
schedule_type: str
backup_kprofiles: bool
backup_cloud_profiles: bool
backup_settings: bool
backup_spools: bool
backup_archives: bool
enabled: bool
last_backup_at: datetime | None
last_backup_status: str | None
last_backup_message: str | None
last_backup_commit_sha: str | None
next_scheduled_run: datetime | None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class GitHubBackupLogResponse(BaseModel):
"""Schema for backup log API response."""
id: int
config_id: int
started_at: datetime
completed_at: datetime | None
status: str
trigger: str
commit_sha: str | None
files_changed: int
error_message: str | None
class Config:
from_attributes = True
class GitHubBackupStatus(BaseModel):
"""Schema for current backup status."""
configured: bool = Field(description="Whether backup is configured")
enabled: bool = Field(description="Whether backup is enabled")
is_running: bool = Field(description="Whether a backup is currently running")
progress: str | None = Field(default=None, description="Current backup progress message")
last_backup_at: datetime | None
last_backup_status: str | None
next_scheduled_run: datetime | None
class GitHubTestConnectionResponse(BaseModel):
"""Schema for test connection response."""
success: bool
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):
"""Schema for manual backup trigger response."""
success: bool
message: str
log_id: int | None = None
commit_sha: str | None = None
files_changed: int = 0