mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +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.
194 lines
6.4 KiB
Python
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
|