feat(backup): Extend Backup to other Git providers (#1160)

This commit is contained in:
BurntOutHylian
2026-05-03 08:12:36 +02:00
committed by GitHub
parent abc8e97050
commit e45f967616
26 changed files with 2191 additions and 460 deletions
+31 -8
View File
@@ -19,6 +19,7 @@ from backend.app.schemas.github_backup import (
GitHubBackupStatus,
GitHubBackupTriggerResponse,
GitHubTestConnectionResponse,
ProviderType,
)
from backend.app.services.github_backup import github_backup_service
@@ -34,6 +35,8 @@ def _config_to_response(config: GitHubBackupConfig) -> dict:
"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,
@@ -86,6 +89,7 @@ async def save_config(
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
@@ -93,11 +97,12 @@ async def save_config(
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)
config.next_scheduled_run = github_backup_service.calculate_next_run(config.schedule_type)
else:
config.next_scheduled_run = None
@@ -108,6 +113,7 @@ async def save_config(
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,
@@ -115,11 +121,12 @@ async def save_config(
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)
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)
@@ -145,8 +152,19 @@ async def update_config(
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.",
)
for key, value in update_dict.items():
if key == "schedule_type" and value is not None:
if key in ("schedule_type", "provider") and value is not None:
setattr(config, key, value.value)
else:
setattr(config, key, value)
@@ -154,7 +172,7 @@ async def update_config(
# 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)
config.next_scheduled_run = github_backup_service.calculate_next_run(config.schedule_type)
else:
config.next_scheduled_run = None
@@ -188,12 +206,13 @@ async def delete_config(
@router.post("/test", response_model=GitHubTestConnectionResponse)
async def test_connection(
repo_url: str = Query(..., description="GitHub repository URL"),
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 GitHub connection with provided credentials."""
result = await github_backup_service.test_connection(repo_url, token)
"""Test Git provider connection with provided credentials."""
result = await github_backup_service.test_connection(repo_url, token, provider=provider)
return GitHubTestConnectionResponse(**result)
@@ -212,7 +231,11 @@ async def test_stored_connection(
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)
test_result = await github_backup_service.test_connection(
config.repository_url,
config.access_token,
provider=config.provider,
)
return GitHubTestConnectionResponse(**test_result)
+6
View File
@@ -1856,6 +1856,12 @@ async def run_migrations(conn):
)
)
# Migration: Add provider column to github_backup_config for multi-provider support
await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN provider VARCHAR(30) DEFAULT 'github'")
# Migration: Add allow_insecure_http column to github_backup_config for self-hosted HTTP instances
await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN allow_insecure_http BOOLEAN DEFAULT FALSE")
# Seed default settings keys that must exist on fresh install
default_settings = [
("advanced_auth_enabled", "false"),
+2
View File
@@ -17,6 +17,8 @@ class GitHubBackupConfig(Base):
repository_url: Mapped[str] = mapped_column(String(500)) # Full GitHub URL
access_token: Mapped[str] = mapped_column(Text) # Personal Access Token
branch: Mapped[str] = mapped_column(String(100), default="main")
provider: Mapped[str] = mapped_column(String(30), default="github")
allow_insecure_http: Mapped[bool] = mapped_column(Boolean, default=False)
# Schedule configuration
schedule_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
+53 -26
View File
@@ -3,7 +3,7 @@
import re
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, model_validator
from backend.app.core.compat import StrEnum
@@ -16,12 +16,22 @@ class ScheduleType(StrEnum):
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="GitHub repository URL")
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")
@@ -32,21 +42,31 @@ class GitHubBackupConfigCreate(BaseModel):
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")
@field_validator("repository_url")
@classmethod
def validate_repo_url(cls, v: str) -> str:
"""Validate GitHub repository URL format."""
# Accept various GitHub URL formats
patterns = [
r"^https://github\.com/[\w.-]+/[\w.-]+(?:\.git)?$",
r"^git@github\.com:[\w.-]+/[\w.-]+(?:\.git)?$",
@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)?$",
]
v = v.strip().rstrip("/")
if not any(re.match(p, v) for p in patterns):
raise ValueError("Invalid GitHub repository URL. Expected format: https://github.com/owner/repo")
return v
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):
@@ -55,6 +75,7 @@ class GitHubBackupConfigUpdate(BaseModel):
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
@@ -65,21 +86,25 @@ class GitHubBackupConfigUpdate(BaseModel):
backup_spools: bool | None = None
backup_archives: bool | None = None
allow_insecure_http: bool | None = None
enabled: bool | None = None
@field_validator("repository_url")
@classmethod
def validate_repo_url(cls, v: str | None) -> str | None:
if v is None:
return v
patterns = [
r"^https://github\.com/[\w.-]+/[\w.-]+(?:\.git)?$",
r"^git@github\.com:[\w.-]+/[\w.-]+(?:\.git)?$",
@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)?$",
]
v = v.strip().rstrip("/")
if not any(re.match(p, v) for p in patterns):
raise ValueError("Invalid GitHub repository URL")
return v
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):
@@ -89,6 +114,8 @@ class GitHubBackupConfigResponse(BaseModel):
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
@@ -0,0 +1,4 @@
from backend.app.services.git_providers.base import GitProviderBackend
from backend.app.services.git_providers.factory import get_provider_backend
__all__ = ["GitProviderBackend", "get_provider_backend"]
@@ -0,0 +1,56 @@
"""Abstract base class for Git hosting provider backends."""
import hashlib
from abc import ABC, abstractmethod
import httpx
class GitProviderBackend(ABC):
"""Abstract base for Git hosting provider API backends."""
@staticmethod
def _blob_sha(content_bytes: bytes) -> str:
"""Compute the git blob SHA for content_bytes (sha1("blob {len}\\0" + data))."""
return hashlib.sha1(
f"blob {len(content_bytes)}\0".encode() + content_bytes, usedforsecurity=False
).hexdigest()
@staticmethod
def _truncated_response_text(response: httpx.Response, max_length: int = 200) -> str:
"""Return a bounded response body for errors surfaced to logs/UI."""
text = response.text
if len(text) <= max_length:
return text
return f"{text[: max_length - 3]}..."
def get_headers(self, token: str) -> dict:
"""Return HTTP headers for authenticated API requests."""
return {
"Authorization": f"token {token}",
"Accept": "application/vnd.github.v3+json",
"User-Agent": "Bambuddy-Backup",
}
@abstractmethod
def parse_repo_url(self, url: str) -> tuple[str, str]:
"""Return (owner, repo) extracted from the repository URL."""
@abstractmethod
def get_api_base(self, repo_url: str) -> str:
"""Return the API base URL for this provider instance."""
@abstractmethod
async def test_connection(self, repo_url: str, token: str, client: httpx.AsyncClient) -> dict:
"""Test API connectivity and push permissions. Returns success/message/repo_name/permissions."""
@abstractmethod
async def push_files(
self,
repo_url: str,
token: str,
branch: str,
files: dict,
client: httpx.AsyncClient,
) -> dict:
"""Push files to the repository. Returns status/message/commit_sha/files_changed."""
@@ -0,0 +1,22 @@
"""Factory for instantiating the correct Git provider backend."""
from backend.app.services.git_providers.base import GitProviderBackend
from backend.app.services.git_providers.forgejo import ForgejoBackend
from backend.app.services.git_providers.gitea import GiteaBackend
from backend.app.services.git_providers.github import GitHubBackend
from backend.app.services.git_providers.gitlab import GitLabBackend
_BACKENDS: dict[str, type[GitProviderBackend]] = {
"github": GitHubBackend,
"gitea": GiteaBackend,
"forgejo": ForgejoBackend,
"gitlab": GitLabBackend,
}
def get_provider_backend(provider: str) -> GitProviderBackend:
"""Return an instantiated backend for the given provider key."""
backend_cls = _BACKENDS.get(provider)
if backend_cls is None:
raise ValueError(f"Unknown Git provider: {provider!r}")
return backend_cls()
@@ -0,0 +1,11 @@
"""Forgejo backend — currently API-compatible with Gitea (/api/v1)."""
from backend.app.services.git_providers.gitea import GiteaBackend
class ForgejoBackend(GiteaBackend):
"""Backend for Forgejo instances.
Currently API-compatible with Gitea (/api/v1). Override methods here
as the two projects' APIs diverge.
"""
@@ -0,0 +1,44 @@
"""Gitea backend — uses the Git Data API inherited from GitHubBackend."""
import re
from backend.app.services.git_providers.github import GitHubBackend
class GiteaBackend(GitHubBackend):
"""Backend for Gitea instances.
Gitea's Git Data API (/api/v1/repos/{owner}/{repo}/git/...) is compatible
with GitHub's, so push_files, _create_branch_and_push, and _create_initial_commit
are inherited unchanged. Only the API base URL and Accept header differ.
"""
def parse_repo_url(self, url: str) -> tuple[str, str]:
"""Return (owner, repo) — accepts both https:// and http:// for self-hosted instances."""
if not url or len(url) > 500:
raise ValueError("Invalid Git URL: URL too long or empty")
match = re.match(
r"https?://[\w.\-]+(:\d+)?/([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?/?$",
url,
)
if match:
return match.group(2), match.group(3).removesuffix(".git")
match = re.match(
r"git@[\w.\-]+:([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?$",
url,
)
if match:
return match.group(1), match.group(2).removesuffix(".git")
raise ValueError(f"Cannot parse repository URL: {url}")
def get_api_base(self, repo_url: str) -> str:
"""Derive API base from the repository URL's scheme and host."""
match = re.match(r"(https?://[\w.\-]+(:\d+)?)/", repo_url)
if match:
return f"{match.group(1)}/api/v1"
raise ValueError(f"Cannot derive API base from URL: {repo_url}")
def get_headers(self, token: str) -> dict:
headers = super().get_headers(token)
headers["Accept"] = "application/json"
return headers
@@ -0,0 +1,335 @@
"""GitHub backend — implements GitProviderBackend using the GitHub Git Data API."""
import base64
import json
import logging
import re
from datetime import datetime, timezone
import httpx
from backend.app.services.git_providers.base import GitProviderBackend
logger = logging.getLogger(__name__)
class GitHubBackend(GitProviderBackend):
"""Backend for github.com using the GitHub Git Data API."""
def get_api_base(self, repo_url: str) -> str:
m = re.match(r"https?://([\w.\-]+(:\d+)?)/", repo_url)
if m:
host = m.group(1)
return "https://api.github.com" if host == "github.com" else f"https://{host}/api/v3"
m = re.match(r"git@([\w.\-]+):", repo_url)
if m:
host = m.group(1)
return "https://api.github.com" if host == "github.com" else f"https://{host}/api/v3"
return "https://api.github.com"
def parse_repo_url(self, url: str) -> tuple[str, str]:
"""Return (owner, repo) from a Git HTTPS or SSH URL."""
if not url or len(url) > 500:
raise ValueError("Invalid Git URL: URL too long or empty")
# HTTPS: https://<host>[:<port>]/<owner>/<repo>[.git][/]
match = re.match(
r"https://[\w.\-]+(:\d+)?/([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?/?$",
url,
)
if match:
return match.group(2), match.group(3).removesuffix(".git")
# SSH: git@<host>:<owner>/<repo>[.git]
match = re.match(
r"git@[\w.\-]+:([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?$",
url,
)
if match:
return match.group(1), match.group(2).removesuffix(".git")
raise ValueError(f"Cannot parse repository URL: {url}")
async def test_connection(self, repo_url: str, token: str, client: httpx.AsyncClient) -> dict:
"""Test API access and push permission for the repository."""
try:
owner, repo = self.parse_repo_url(repo_url)
api_base = self.get_api_base(repo_url)
headers = self.get_headers(token)
response = await client.get(f"{api_base}/repos/{owner}/{repo}", headers=headers)
if response.status_code == 401:
return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
if response.status_code == 404:
return {
"success": False,
"message": "Repository not found. Check URL and token permissions.",
"repo_name": None,
"permissions": None,
}
if response.status_code != 200:
return {
"success": False,
"message": f"API error: {response.status_code}",
"repo_name": None,
"permissions": None,
}
data = response.json()
permissions = data.get("permissions", {})
if not permissions.get("push", False):
return {
"success": False,
"message": "Token does not have push permission to this repository",
"repo_name": data.get("full_name"),
"permissions": permissions,
}
return {
"success": True,
"message": "Connection successful",
"repo_name": data.get("full_name"),
"permissions": permissions,
}
except Exception as e:
logger.error("Git connection test failed: %s", e)
return {
"success": False,
"message": f"Connection failed: {type(e).__name__}",
"repo_name": None,
"permissions": None,
}
async def push_files(
self,
repo_url: str,
token: str,
branch: str,
files: dict,
client: httpx.AsyncClient,
) -> dict:
"""Push files to the repository using the Git Data API."""
try:
owner, repo = self.parse_repo_url(repo_url)
api_base = self.get_api_base(repo_url)
headers = self.get_headers(token)
ref_response = await client.get(f"{api_base}/repos/{owner}/{repo}/git/refs/heads/{branch}", headers=headers)
if ref_response.status_code == 404:
return await self._create_branch_and_push(
client, headers, api_base, owner, repo, branch, files, repo_url, token
)
if ref_response.status_code != 200:
return {
"status": "failed",
"message": f"Failed to get branch ref: {ref_response.status_code}",
"error": self._truncated_response_text(ref_response),
}
current_commit_sha = ref_response.json()["object"]["sha"]
commit_response = await client.get(
f"{api_base}/repos/{owner}/{repo}/git/commits/{current_commit_sha}", headers=headers
)
if commit_response.status_code != 200:
return {"status": "failed", "message": "Failed to get current commit"}
current_tree_sha = commit_response.json()["tree"]["sha"]
tree_response = await client.get(
f"{api_base}/repos/{owner}/{repo}/git/trees/{current_tree_sha}?recursive=1", headers=headers
)
existing_files: dict[str, str] = {}
if tree_response.status_code == 200:
for item in tree_response.json().get("tree", []):
if item["type"] == "blob":
existing_files[item["path"]] = item["sha"]
tree_items = []
files_changed = 0
for path, content in files.items():
content_str = json.dumps(content, indent=2, default=str)
content_bytes = content_str.encode("utf-8")
content_sha = self._blob_sha(content_bytes)
if path in existing_files and existing_files[path] == content_sha:
continue
blob_response = await client.post(
f"{api_base}/repos/{owner}/{repo}/git/blobs",
headers=headers,
json={"content": base64.b64encode(content_bytes).decode(), "encoding": "base64"},
)
if blob_response.status_code != 201:
logger.error("Failed to create blob for %s: %s", path, self._truncated_response_text(blob_response))
continue
tree_items.append({"path": path, "mode": "100644", "type": "blob", "sha": blob_response.json()["sha"]})
files_changed += 1
if not tree_items:
return {"status": "skipped", "message": "No changes to commit", "commit_sha": None, "files_changed": 0}
tree_response = await client.post(
f"{api_base}/repos/{owner}/{repo}/git/trees",
headers=headers,
json={"base_tree": current_tree_sha, "tree": tree_items},
)
if tree_response.status_code != 201:
return {
"status": "failed",
"message": f"Failed to create tree: {self._truncated_response_text(tree_response)}",
}
new_tree_sha = tree_response.json()["sha"]
commit_message = f"Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}"
commit_response = await client.post(
f"{api_base}/repos/{owner}/{repo}/git/commits",
headers=headers,
json={"message": commit_message, "tree": new_tree_sha, "parents": [current_commit_sha]},
)
if commit_response.status_code != 201:
return {
"status": "failed",
"message": f"Failed to create commit: {self._truncated_response_text(commit_response)}",
}
new_commit_sha = commit_response.json()["sha"]
ref_update = await client.patch(
f"{api_base}/repos/{owner}/{repo}/git/refs/heads/{branch}",
headers=headers,
json={"sha": new_commit_sha},
)
if ref_update.status_code != 200:
return {
"status": "failed",
"message": f"Failed to update branch: {self._truncated_response_text(ref_update)}",
}
return {
"status": "success",
"message": f"Backup successful - {files_changed} files updated",
"commit_sha": new_commit_sha,
"files_changed": files_changed,
}
except Exception as e:
logger.error("Push to Git failed: %s", e)
return {"status": "failed", "message": str(e), "error": str(e)}
async def _create_branch_and_push(
self,
client: httpx.AsyncClient,
headers: dict,
api_base: str,
owner: str,
repo: str,
branch: str,
files: dict,
repo_url: str,
token: str,
) -> dict:
"""Create branch (from default branch or as initial commit) then push."""
try:
repo_response = await client.get(f"{api_base}/repos/{owner}/{repo}", headers=headers)
if repo_response.status_code != 200:
return {"status": "failed", "message": "Failed to get repo info"}
default_branch = repo_response.json().get("default_branch", "main")
ref_response = await client.get(
f"{api_base}/repos/{owner}/{repo}/git/refs/heads/{default_branch}", headers=headers
)
if ref_response.status_code != 200:
return await self._create_initial_commit(client, headers, api_base, owner, repo, branch, files)
base_sha = ref_response.json()["object"]["sha"]
create_ref = await client.post(
f"{api_base}/repos/{owner}/{repo}/git/refs",
headers=headers,
json={"ref": f"refs/heads/{branch}", "sha": base_sha},
)
if create_ref.status_code != 201:
return {
"status": "failed",
"message": f"Failed to create branch: {self._truncated_response_text(create_ref)}",
}
return await self.push_files(repo_url, token, branch, files, client)
except Exception as e:
return {"status": "failed", "message": str(e)}
async def _create_initial_commit(
self,
client: httpx.AsyncClient,
headers: dict,
api_base: str,
owner: str,
repo: str,
branch: str,
files: dict,
) -> dict:
"""Create the first commit in an empty repository."""
try:
tree_items = []
for path, content in files.items():
content_str = json.dumps(content, indent=2, default=str)
blob_response = await client.post(
f"{api_base}/repos/{owner}/{repo}/git/blobs",
headers=headers,
json={"content": base64.b64encode(content_str.encode()).decode(), "encoding": "base64"},
)
if blob_response.status_code == 201:
tree_items.append(
{"path": path, "mode": "100644", "type": "blob", "sha": blob_response.json()["sha"]}
)
tree_response = await client.post(
f"{api_base}/repos/{owner}/{repo}/git/trees",
headers=headers,
json={"tree": tree_items},
)
if tree_response.status_code != 201:
return {"status": "failed", "message": "Failed to create tree"}
tree_sha = tree_response.json()["sha"]
commit_response = await client.post(
f"{api_base}/repos/{owner}/{repo}/git/commits",
headers=headers,
json={
"message": f"Initial Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}",
"tree": tree_sha,
},
)
if commit_response.status_code != 201:
return {"status": "failed", "message": "Failed to create commit"}
commit_sha = commit_response.json()["sha"]
ref_response = await client.post(
f"{api_base}/repos/{owner}/{repo}/git/refs",
headers=headers,
json={"ref": f"refs/heads/{branch}", "sha": commit_sha},
)
if ref_response.status_code != 201:
return {"status": "failed", "message": "Failed to create branch ref"}
return {
"status": "success",
"message": f"Initial backup created - {len(files)} files",
"commit_sha": commit_sha,
"files_changed": len(files),
}
except Exception as e:
return {"status": "failed", "message": str(e)}
@@ -0,0 +1,257 @@
"""GitLab backend — implements GitProviderBackend using the GitLab REST API v4."""
import base64
import json
import logging
import re
import urllib.parse
from datetime import datetime, timezone
import httpx
from backend.app.services.git_providers.base import GitProviderBackend
logger = logging.getLogger(__name__)
class GitLabBackend(GitProviderBackend):
"""Backend for gitlab.com and self-hosted GitLab instances."""
def get_api_base(self, repo_url: str) -> str:
match = re.match(r"(https?://[\w.\-]+(:\d+)?)/", repo_url)
if not match:
raise ValueError(f"Cannot derive API base from URL: {repo_url}")
return f"{match.group(1)}/api/v4"
def get_headers(self, token: str) -> dict:
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
def parse_repo_url(self, url: str) -> tuple[str, str]:
"""Return (namespace, repo) from HTTPS or SSH URL.
namespace may include subgroups, e.g. 'group/subgroup' for
gitlab.com/group/subgroup/project. Callers join them with '/' and
URL-encode the result for /api/v4/projects/{encoded_path}.
"""
if not url or len(url) > 500:
raise ValueError("Invalid Git URL: URL too long or empty")
match = re.match(r"https?://[\w.\-]+(:\d+)?/(.+?)(?:\.git)?/?$", url)
if match:
full_path = match.group(2)
if "/" not in full_path:
raise ValueError(f"Cannot parse repository URL: {url}")
namespace, _, repo = full_path.rpartition("/")
return namespace, repo
match = re.match(r"git@[\w.\-]+:(.+?)(?:\.git)?$", url)
if match:
full_path = match.group(1)
if "/" not in full_path:
raise ValueError(f"Cannot parse repository URL: {url}")
namespace, _, repo = full_path.rpartition("/")
return namespace, repo
raise ValueError(f"Cannot parse repository URL: {url}")
async def test_connection(self, repo_url: str, token: str, client: httpx.AsyncClient) -> dict:
try:
owner, repo = self.parse_repo_url(repo_url)
api_base = self.get_api_base(repo_url)
headers = self.get_headers(token)
encoded_path = urllib.parse.quote(f"{owner}/{repo}", safe="")
response = await client.get(f"{api_base}/projects/{encoded_path}", headers=headers)
if response.status_code == 401:
return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
if response.status_code == 404:
return {
"success": False,
"message": "Repository not found. Check URL and token permissions.",
"repo_name": None,
"permissions": None,
}
if response.status_code != 200:
return {
"success": False,
"message": f"API error: {response.status_code}",
"repo_name": None,
"permissions": None,
}
data = response.json()
perms = data.get("permissions") or {}
project_level = (perms.get("project_access") or {}).get("access_level", 0)
group_level = (perms.get("group_access") or {}).get("access_level", 0)
effective = max(project_level, group_level)
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,
}
return {
"success": True,
"message": "Connection successful",
"repo_name": data.get("name_with_namespace"),
"permissions": perms,
}
except Exception as e:
logger.error("GitLab connection test failed: %s", e)
return {
"success": False,
"message": f"Connection failed: {type(e).__name__}",
"repo_name": None,
"permissions": None,
}
async def push_files(
self,
repo_url: str,
token: str,
branch: str,
files: dict,
client: httpx.AsyncClient,
) -> dict:
try:
owner, repo = self.parse_repo_url(repo_url)
api_base = self.get_api_base(repo_url)
headers = self.get_headers(token)
encoded_path = urllib.parse.quote(f"{owner}/{repo}", safe="")
encoded_branch = urllib.parse.quote(branch, safe="")
branch_response = await client.get(
f"{api_base}/projects/{encoded_path}/repository/branches/{encoded_branch}",
headers=headers,
)
if branch_response.status_code == 404:
proj_response = await client.get(f"{api_base}/projects/{encoded_path}", headers=headers)
if proj_response.status_code != 200:
return {"status": "failed", "message": "Failed to get project info"}
default_branch = proj_response.json().get("default_branch", "main")
default_encoded = urllib.parse.quote(default_branch, safe="")
default_response = await client.get(
f"{api_base}/projects/{encoded_path}/repository/branches/{default_encoded}",
headers=headers,
)
if default_response.status_code != 200:
return await self._create_initial_commit(client, headers, api_base, encoded_path, branch, files)
create_response = await client.post(
f"{api_base}/projects/{encoded_path}/repository/branches",
headers=headers,
json={"branch": branch, "ref": default_branch},
)
if create_response.status_code not in (200, 201):
return {"status": "failed", "message": f"Failed to create branch: {create_response.status_code}"}
elif branch_response.status_code != 200:
return {"status": "failed", "message": f"Failed to check branch: {branch_response.status_code}"}
existing_blobs: dict[str, str] = {}
page = 1
while True:
tree_response = await client.get(
f"{api_base}/projects/{encoded_path}/repository/tree",
headers=headers,
params={"recursive": "true", "ref": branch, "per_page": 100, "page": page},
)
if tree_response.status_code != 200:
break
items = tree_response.json()
if not items:
break
for item in items:
if item.get("type") == "blob":
existing_blobs[item["path"]] = item["id"]
page += 1
actions = []
for path, content in files.items():
content_str = json.dumps(content, indent=2, default=str)
content_bytes = content_str.encode("utf-8")
content_sha = self._blob_sha(content_bytes)
if path in existing_blobs and existing_blobs[path] == content_sha:
continue
actions.append(
{
"action": "update" if path in existing_blobs else "create",
"file_path": path,
"content": base64.b64encode(content_bytes).decode(),
"encoding": "base64",
}
)
if not actions:
return {"status": "skipped", "message": "No changes to commit", "commit_sha": None, "files_changed": 0}
commit_message = f"Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}"
commit_response = await client.post(
f"{api_base}/projects/{encoded_path}/repository/commits",
headers=headers,
json={"branch": branch, "commit_message": commit_message, "actions": actions},
)
if commit_response.status_code not in (200, 201):
return {
"status": "failed",
"message": f"Failed to create commit: {self._truncated_response_text(commit_response)}",
}
return {
"status": "success",
"message": f"Backup successful - {len(actions)} files updated",
"commit_sha": commit_response.json().get("id"),
"files_changed": len(actions),
}
except Exception as e:
logger.error("Push to GitLab failed: %s", e)
return {"status": "failed", "message": str(e), "error": str(e)}
async def _create_initial_commit(
self,
client: httpx.AsyncClient,
headers: dict,
api_base: str,
encoded_path: str,
branch: str,
files: dict,
) -> dict:
"""Create the first commit in an empty repository."""
try:
actions = []
for path, content in files.items():
content_str = json.dumps(content, indent=2, default=str)
actions.append(
{
"action": "create",
"file_path": path,
"content": base64.b64encode(content_str.encode()).decode(),
"encoding": "base64",
}
)
commit_message = f"Initial Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}"
commit_response = await client.post(
f"{api_base}/projects/{encoded_path}/repository/commits",
headers=headers,
json={"branch": branch, "commit_message": commit_message, "actions": actions, "start_branch": branch},
)
if commit_response.status_code not in (200, 201):
return {
"status": "failed",
"message": f"Failed to create initial commit: {self._truncated_response_text(commit_response)}",
}
return {
"status": "success",
"message": f"Initial backup created - {len(files)} files",
"commit_sha": commit_response.json().get("id"),
"files_changed": len(files),
}
except Exception as e:
return {"status": "failed", "message": str(e)}
+31 -349
View File
@@ -4,11 +4,7 @@ Handles scheduled and on-demand backups of K-profiles and cloud profiles to GitH
"""
import asyncio
import base64
import hashlib
import json
import logging
import re
from datetime import datetime, timedelta, timezone
import httpx
@@ -22,6 +18,7 @@ from backend.app.models.printer import Printer
from backend.app.models.settings import Settings
from backend.app.models.spool import Spool
from backend.app.models.spool_usage_history import SpoolUsageHistory
from backend.app.services.git_providers.factory import get_provider_backend
from backend.app.services.printer_manager import printer_manager
logger = logging.getLogger(__name__)
@@ -33,6 +30,13 @@ SCHEDULE_INTERVALS = {
"weekly": 604800,
}
_PROVIDER_DISPLAY_NAMES = {
"github": "GitHub",
"gitlab": "GitLab",
"gitea": "Gitea",
"forgejo": "Forgejo",
}
class GitHubBackupService:
"""Service for backing up profiles to GitHub."""
@@ -97,104 +101,17 @@ class GitHubBackupService:
logger.info("Running scheduled backup for config %s", config.id)
await self.run_backup(config.id, trigger="scheduled")
def _calculate_next_run(self, schedule_type: str, from_time: datetime | None = None) -> datetime:
def calculate_next_run(self, schedule_type: str, from_time: datetime | None = None) -> datetime:
"""Calculate the next scheduled run time."""
now = from_time or datetime.now(timezone.utc)
interval = SCHEDULE_INTERVALS.get(schedule_type, SCHEDULE_INTERVALS["daily"])
return now + timedelta(seconds=interval)
async def test_connection(self, repo_url: str, token: str) -> dict:
"""Test GitHub connection and permissions.
Args:
repo_url: GitHub repository URL
token: Personal Access Token
Returns:
dict with success, message, repo_name, permissions
"""
try:
owner, repo = self._parse_repo_url(repo_url)
client = await self._get_client()
# Test API access
response = await client.get(
f"https://api.github.com/repos/{owner}/{repo}",
headers={
"Authorization": f"token {token}",
"Accept": "application/vnd.github.v3+json",
"User-Agent": "Bambuddy-Backup",
},
)
if response.status_code == 401:
return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
if response.status_code == 404:
return {
"success": False,
"message": "Repository not found. Check URL and token permissions.",
"repo_name": None,
"permissions": None,
}
if response.status_code != 200:
return {
"success": False,
"message": f"GitHub API error: {response.status_code}",
"repo_name": None,
"permissions": None,
}
data = response.json()
permissions = data.get("permissions", {})
# Check for push permission
if not permissions.get("push", False):
return {
"success": False,
"message": "Token does not have push permission to this repository",
"repo_name": data.get("full_name"),
"permissions": permissions,
}
return {
"success": True,
"message": "Connection successful",
"repo_name": data.get("full_name"),
"permissions": permissions,
}
except Exception as e:
logger.error("GitHub connection test failed: %s", e)
# Sanitize error - don't expose internal details
error_type = type(e).__name__
return {
"success": False,
"message": f"Connection failed: {error_type}",
"repo_name": None,
"permissions": None,
}
def _parse_repo_url(self, url: str) -> tuple[str, str]:
"""Parse owner and repo from GitHub URL."""
# Limit URL length to prevent ReDoS attacks
if not url or len(url) > 500:
raise ValueError("Invalid GitHub URL: URL too long or empty")
# Handle HTTPS URLs - use atomic groups via limited character classes
# GitHub usernames: 1-39 chars, alphanumeric and hyphens
# Repo names: 1-100 chars, alphanumeric, hyphens, underscores, dots
match = re.match(r"https://github\.com/([\w-]{1,39})/([\w.\-]{1,100})(?:\.git)?/?$", url)
if match:
return match.group(1), match.group(2)
# Handle SSH URLs
match = re.match(r"git@github\.com:([\w-]{1,39})/([\w.\-]{1,100})(?:\.git)?$", url)
if match:
return match.group(1), match.group(2)
raise ValueError(f"Invalid GitHub URL: {url}")
async def test_connection(self, repo_url: str, token: str, provider: str = "github") -> dict:
"""Test connection and permissions for the given provider."""
backend = get_provider_backend(provider)
client = await self._get_client()
return await backend.test_connection(repo_url, token, client)
async def run_backup(self, config_id: int, trigger: str = "manual") -> dict:
"""Run a backup operation.
@@ -245,7 +162,7 @@ class GitHubBackupService:
config.last_backup_status = "skipped"
config.last_backup_message = "No data to backup"
if config.schedule_enabled:
config.next_scheduled_run = self._calculate_next_run(config.schedule_type)
config.next_scheduled_run = self.calculate_next_run(config.schedule_type)
await db.commit()
return {
"success": True,
@@ -255,9 +172,9 @@ class GitHubBackupService:
"files_changed": 0,
}
# Push to GitHub
self._backup_progress = "Pushing to GitHub..."
push_result = await self._push_to_github(config, backup_data)
provider_name = _PROVIDER_DISPLAY_NAMES.get(config.provider, config.provider)
self._backup_progress = f"Pushing to {provider_name}..."
push_result = await self._push_to_provider(config, backup_data)
# Update log and config
log.status = push_result["status"]
@@ -272,7 +189,7 @@ class GitHubBackupService:
config.last_backup_commit_sha = push_result.get("commit_sha")
if config.schedule_enabled:
config.next_scheduled_run = self._calculate_next_run(config.schedule_type)
config.next_scheduled_run = self.calculate_next_run(config.schedule_type)
await db.commit()
@@ -295,7 +212,7 @@ class GitHubBackupService:
config.last_backup_message = str(e)
if config.schedule_enabled:
config.next_scheduled_run = self._calculate_next_run(config.schedule_type)
config.next_scheduled_run = self.calculate_next_run(config.schedule_type)
await db.commit()
return {
@@ -609,252 +526,17 @@ class GitHubBackupService:
logger.info("Collected %d print archives", len(archive_list))
async def _push_to_github(self, config: GitHubBackupConfig, files: dict) -> dict:
"""Push files to GitHub using the GitHub API.
Uses the Git Data API to create blobs, tree, and commit.
Returns:
dict with status, message, commit_sha, files_changed
"""
try:
owner, repo = self._parse_repo_url(config.repository_url)
branch = config.branch
client = await self._get_client()
headers = {
"Authorization": f"token {config.access_token}",
"Accept": "application/vnd.github.v3+json",
"User-Agent": "Bambuddy-Backup",
}
# Get current branch reference
ref_response = await client.get(
f"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/{branch}", headers=headers
)
if ref_response.status_code == 404:
# Branch doesn't exist, need to create it from default branch
return await self._create_branch_and_push(client, headers, owner, repo, branch, files)
if ref_response.status_code != 200:
return {
"status": "failed",
"message": f"Failed to get branch ref: {ref_response.status_code}",
"error": ref_response.text,
}
ref_data = ref_response.json()
current_commit_sha = ref_data["object"]["sha"]
# Get the current tree
commit_response = await client.get(
f"https://api.github.com/repos/{owner}/{repo}/git/commits/{current_commit_sha}", headers=headers
)
if commit_response.status_code != 200:
return {"status": "failed", "message": "Failed to get current commit"}
current_tree_sha = commit_response.json()["tree"]["sha"]
# Get existing files to check for changes
tree_response = await client.get(
f"https://api.github.com/repos/{owner}/{repo}/git/trees/{current_tree_sha}?recursive=1", headers=headers
)
existing_files = {}
if tree_response.status_code == 200:
for item in tree_response.json().get("tree", []):
if item["type"] == "blob":
existing_files[item["path"]] = item["sha"]
# Create blobs for changed files
tree_items = []
files_changed = 0
for path, content in files.items():
content_str = json.dumps(content, indent=2, default=str)
content_bytes = content_str.encode("utf-8")
content_sha = hashlib.sha1(
f"blob {len(content_bytes)}\0".encode() + content_bytes, usedforsecurity=False
).hexdigest()
# Skip if file hasn't changed
if path in existing_files and existing_files[path] == content_sha:
continue
# Create blob
blob_response = await client.post(
f"https://api.github.com/repos/{owner}/{repo}/git/blobs",
headers=headers,
json={"content": base64.b64encode(content_bytes).decode(), "encoding": "base64"},
)
if blob_response.status_code != 201:
logger.error("Failed to create blob for %s: %s", path, blob_response.text)
continue
blob_sha = blob_response.json()["sha"]
tree_items.append({"path": path, "mode": "100644", "type": "blob", "sha": blob_sha})
files_changed += 1
if not tree_items:
return {"status": "skipped", "message": "No changes to commit", "commit_sha": None, "files_changed": 0}
# Create new tree
tree_response = await client.post(
f"https://api.github.com/repos/{owner}/{repo}/git/trees",
headers=headers,
json={"base_tree": current_tree_sha, "tree": tree_items},
)
if tree_response.status_code != 201:
return {"status": "failed", "message": f"Failed to create tree: {tree_response.text}"}
new_tree_sha = tree_response.json()["sha"]
# Create commit
commit_message = f"Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}"
commit_response = await client.post(
f"https://api.github.com/repos/{owner}/{repo}/git/commits",
headers=headers,
json={"message": commit_message, "tree": new_tree_sha, "parents": [current_commit_sha]},
)
if commit_response.status_code != 201:
return {"status": "failed", "message": f"Failed to create commit: {commit_response.text}"}
new_commit_sha = commit_response.json()["sha"]
# Update branch reference
ref_update = await client.patch(
f"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/{branch}",
headers=headers,
json={"sha": new_commit_sha},
)
if ref_update.status_code != 200:
return {"status": "failed", "message": f"Failed to update branch: {ref_update.text}"}
return {
"status": "success",
"message": f"Backup successful - {files_changed} files updated",
"commit_sha": new_commit_sha,
"files_changed": files_changed,
}
except Exception as e:
logger.error("Push to GitHub failed: %s", e)
return {"status": "failed", "message": str(e), "error": str(e)}
async def _create_branch_and_push(
self, client: httpx.AsyncClient, headers: dict, owner: str, repo: str, branch: str, files: dict
) -> dict:
"""Create a new branch and push files when branch doesn't exist."""
try:
# Get default branch
repo_response = await client.get(f"https://api.github.com/repos/{owner}/{repo}", headers=headers)
if repo_response.status_code != 200:
return {"status": "failed", "message": "Failed to get repo info"}
default_branch = repo_response.json().get("default_branch", "main")
# Get default branch ref
ref_response = await client.get(
f"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/{default_branch}", headers=headers
)
if ref_response.status_code != 200:
# Empty repo - create initial commit
return await self._create_initial_commit(client, headers, owner, repo, branch, files)
base_sha = ref_response.json()["object"]["sha"]
# Create new branch
create_ref = await client.post(
f"https://api.github.com/repos/{owner}/{repo}/git/refs",
headers=headers,
json={"ref": f"refs/heads/{branch}", "sha": base_sha},
)
if create_ref.status_code != 201:
return {"status": "failed", "message": f"Failed to create branch: {create_ref.text}"}
# Now push to the new branch (recursive call will find the branch)
return await self._push_to_github(
type(
"Config",
(),
{
"repository_url": f"https://github.com/{owner}/{repo}",
"access_token": headers["Authorization"].replace("token ", ""),
"branch": branch,
},
)(),
files,
)
except Exception as e:
return {"status": "failed", "message": str(e)}
async def _create_initial_commit(
self, client: httpx.AsyncClient, headers: dict, owner: str, repo: str, branch: str, files: dict
) -> dict:
"""Create initial commit in an empty repository."""
try:
# Create blobs
tree_items = []
for path, content in files.items():
content_str = json.dumps(content, indent=2, default=str)
blob_response = await client.post(
f"https://api.github.com/repos/{owner}/{repo}/git/blobs",
headers=headers,
json={"content": base64.b64encode(content_str.encode()).decode(), "encoding": "base64"},
)
if blob_response.status_code == 201:
tree_items.append(
{"path": path, "mode": "100644", "type": "blob", "sha": blob_response.json()["sha"]}
)
# Create tree
tree_response = await client.post(
f"https://api.github.com/repos/{owner}/{repo}/git/trees",
headers=headers,
json={"tree": tree_items},
)
if tree_response.status_code != 201:
return {"status": "failed", "message": "Failed to create tree"}
tree_sha = tree_response.json()["sha"]
# Create commit (no parents for initial)
commit_response = await client.post(
f"https://api.github.com/repos/{owner}/{repo}/git/commits",
headers=headers,
json={
"message": f"Initial Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}",
"tree": tree_sha,
},
)
if commit_response.status_code != 201:
return {"status": "failed", "message": "Failed to create commit"}
commit_sha = commit_response.json()["sha"]
# Create branch ref
ref_response = await client.post(
f"https://api.github.com/repos/{owner}/{repo}/git/refs",
headers=headers,
json={"ref": f"refs/heads/{branch}", "sha": commit_sha},
)
if ref_response.status_code != 201:
return {"status": "failed", "message": "Failed to create branch ref"}
return {
"status": "success",
"message": f"Initial backup created - {len(files)} files",
"commit_sha": commit_sha,
"files_changed": len(files),
}
except Exception as e:
return {"status": "failed", "message": str(e)}
async def _push_to_provider(self, config: GitHubBackupConfig, files: dict) -> dict:
"""Push files to the configured Git provider."""
backend = get_provider_backend(config.provider)
client = await self._get_client()
return await backend.push_files(
repo_url=config.repository_url,
token=config.access_token,
branch=config.branch,
files=files,
client=client,
)
@property
def is_running(self) -> bool:
@@ -162,6 +162,41 @@ class TestGitHubBackupConfigAPI:
assert result["backup_kprofiles"] is True
assert result["backup_settings"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_config_rejects_disabling_insecure_http_for_stored_http_url(
self, async_client: AsyncClient
):
"""Verify PATCH rejects leaving a stored HTTP URL without explicit insecure-HTTP allowance."""
create_data = {
"repository_url": "http://git.example.com/test/httprepo",
"access_token": "gitea_token",
"branch": "main",
"provider": "gitea",
"allow_insecure_http": True,
"schedule_enabled": False,
"schedule_type": "daily",
"backup_kprofiles": True,
"backup_cloud_profiles": True,
"backup_settings": False,
"backup_spools": False,
"backup_archives": False,
"enabled": True,
}
create_response = await async_client.post("/api/v1/github-backup/config", json=create_data)
assert create_response.status_code == 200
response = await async_client.patch("/api/v1/github-backup/config", json={"allow_insecure_http": False})
assert response.status_code == 422
assert "Allow insecure HTTP" in response.json()["detail"]
stored_response = await async_client.get("/api/v1/github-backup/config")
assert stored_response.status_code == 200
stored = stored_response.json()
assert stored["repository_url"] == "http://git.example.com/test/httprepo"
assert stored["allow_insecure_http"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_config(self, async_client: AsyncClient):
+460
View File
@@ -0,0 +1,460 @@
"""Unit tests for the git_providers abstraction package."""
import hashlib
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
from backend.app.services.git_providers.factory import get_provider_backend
from backend.app.services.git_providers.forgejo import ForgejoBackend
from backend.app.services.git_providers.gitea import GiteaBackend
from backend.app.services.git_providers.github import GitHubBackend
from backend.app.services.git_providers.gitlab import GitLabBackend
class TestFactory:
def test_known_providers_return_correct_class(self):
assert isinstance(get_provider_backend("github"), GitHubBackend)
assert isinstance(get_provider_backend("gitea"), GiteaBackend)
assert isinstance(get_provider_backend("forgejo"), ForgejoBackend)
assert isinstance(get_provider_backend("gitlab"), GitLabBackend)
def test_unknown_provider_raises_value_error(self):
with pytest.raises(ValueError, match="Unknown Git provider"):
get_provider_backend("bitbucket")
class TestGitHubBackendParseUrl:
def setup_method(self):
self.backend = GitHubBackend()
def test_https_url(self):
owner, repo = self.backend.parse_repo_url("https://github.com/owner/repo")
assert owner == "owner"
assert repo == "repo"
def test_https_url_with_git_suffix(self):
owner, repo = self.backend.parse_repo_url("https://github.com/owner/repo.git")
assert owner == "owner"
assert repo == "repo"
def test_ssh_url(self):
owner, repo = self.backend.parse_repo_url("git@github.com:owner/repo")
assert owner == "owner"
assert repo == "repo"
def test_ssh_url_with_git_suffix(self):
owner, repo = self.backend.parse_repo_url("git@github.com:owner/repo.git")
assert owner == "owner"
assert repo == "repo"
def test_invalid_url_raises_value_error(self):
with pytest.raises(ValueError, match="Cannot parse repository URL"):
self.backend.parse_repo_url("https://example.com/not-a-repo")
def test_empty_url_raises_value_error(self):
with pytest.raises(ValueError):
self.backend.parse_repo_url("")
class TestGitHubBackendApiBase:
def setup_method(self):
self.backend = GitHubBackend()
def test_github_com_returns_api_github_com(self):
assert self.backend.get_api_base("https://github.com/owner/repo") == "https://api.github.com"
def test_ghe_host_returns_v3_endpoint(self):
assert self.backend.get_api_base("https://github.example.com/owner/repo") == "https://github.example.com/api/v3"
def test_ghe_host_with_port(self):
assert self.backend.get_api_base("https://github.example.com:8443/owner/repo") == "https://github.example.com:8443/api/v3"
def test_ssh_github_com_returns_api_github_com(self):
assert self.backend.get_api_base("git@github.com:owner/repo.git") == "https://api.github.com"
def test_ssh_ghe_host_returns_v3_endpoint(self):
assert self.backend.get_api_base("git@github.example.com:owner/repo.git") == "https://github.example.com/api/v3"
class TestGiteaBackendApiBase:
def setup_method(self):
self.backend = GiteaBackend()
def test_derives_api_base_from_repo_url(self):
result = self.backend.get_api_base("https://git.example.com/owner/repo")
assert result == "https://git.example.com/api/v1"
def test_derives_api_base_with_port(self):
result = self.backend.get_api_base("https://git.example.com:3000/owner/repo")
assert result == "https://git.example.com:3000/api/v1"
def test_invalid_url_raises_value_error(self):
with pytest.raises(ValueError, match="Cannot derive API base"):
self.backend.get_api_base("not-a-url")
def test_parse_url_uses_instance_host(self):
owner, repo = self.backend.parse_repo_url("https://git.example.com/owner/repo")
assert owner == "owner"
assert repo == "repo"
class TestGiteaBackendPushFiles:
def setup_method(self):
self.backend = GiteaBackend()
self.repo_url = "https://git.example.com/owner/repo"
self.token = "gitea-token"
self.branch = "bambuddy-backup"
@pytest.mark.asyncio
async def test_n_files_produce_single_commit(self):
"""All changed files are bundled into one commit via the Git Data API."""
files = {"a.json": {"k": "v1"}, "b.json": {"k": "v2"}}
client = AsyncMock()
client.get = AsyncMock(
side_effect=[
_make_mock_response(200, {"object": {"sha": "base-commit"}}),
_make_mock_response(200, {"tree": {"sha": "base-tree"}}),
_make_mock_response(200, {"tree": []}),
]
)
client.post = AsyncMock(
side_effect=[
_make_mock_response(201, {"sha": "blob1"}),
_make_mock_response(201, {"sha": "blob2"}),
_make_mock_response(201, {"sha": "new-tree"}),
_make_mock_response(201, {"sha": "new-commit"}),
]
)
client.patch = AsyncMock(return_value=_make_mock_response(200, {}))
result = await self.backend.push_files(self.repo_url, self.token, self.branch, files, client)
assert result["status"] == "success"
assert result["files_changed"] == 2
commit_calls = [c for c in client.post.call_args_list if "/git/commits" in c.args[0]]
assert len(commit_calls) == 1
@pytest.mark.asyncio
async def test_uses_gitea_api_v1_base_not_github(self):
"""Git Data API calls target the instance's /api/v1, not api.github.com."""
client = AsyncMock()
client.get = AsyncMock(
side_effect=[
_make_mock_response(200, {"object": {"sha": "base-commit"}}),
_make_mock_response(200, {"tree": {"sha": "base-tree"}}),
_make_mock_response(200, {"tree": []}),
]
)
client.post = AsyncMock(
side_effect=[
_make_mock_response(201, {"sha": "blob1"}),
_make_mock_response(201, {"sha": "new-tree"}),
_make_mock_response(201, {"sha": "new-commit"}),
]
)
client.patch = AsyncMock(return_value=_make_mock_response(200, {}))
await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
first_get_url = client.get.call_args_list[0].args[0]
assert "git.example.com/api/v1" in first_get_url
assert "api.github.com" not in first_get_url
@pytest.mark.asyncio
async def test_skips_unchanged_files(self):
"""Files whose blob SHA matches the existing tree entry are excluded from the commit."""
content = {"name": "my-printer"}
sha = _blob_sha(content)
client = AsyncMock()
client.get = AsyncMock(
side_effect=[
_make_mock_response(200, {"object": {"sha": "base-commit"}}),
_make_mock_response(200, {"tree": {"sha": "base-tree"}}),
_make_mock_response(200, {"tree": [{"type": "blob", "path": "config/printers.json", "sha": sha}]}),
]
)
result = await self.backend.push_files(
self.repo_url, self.token, self.branch, {"config/printers.json": content}, client
)
assert result["status"] == "skipped"
client.post.assert_not_called()
@pytest.mark.asyncio
async def test_creates_missing_branch_via_git_refs_api(self):
"""A missing backup branch is created via the Git Data API refs endpoint."""
client = AsyncMock()
client.get = AsyncMock(
side_effect=[
# branch ref missing
_make_mock_response(404, {}),
# repo info for default branch
_make_mock_response(200, {"default_branch": "main"}),
# default branch ref
_make_mock_response(200, {"object": {"sha": "base-sha"}}),
# second push_files call: branch now exists
_make_mock_response(200, {"object": {"sha": "base-sha"}}),
_make_mock_response(200, {"tree": {"sha": "base-tree"}}),
_make_mock_response(200, {"tree": []}),
]
)
client.post = AsyncMock(
side_effect=[
_make_mock_response(201, {}), # create ref
_make_mock_response(201, {"sha": "blob1"}),
_make_mock_response(201, {"sha": "new-tree"}),
_make_mock_response(201, {"sha": "new-commit"}),
]
)
client.patch = AsyncMock(return_value=_make_mock_response(200, {}))
result = await self.backend.push_files(
self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client
)
assert result["status"] == "success"
ref_create_call = client.post.call_args_list[0]
assert "/git/refs" in ref_create_call.args[0]
assert ref_create_call.kwargs["json"]["ref"] == f"refs/heads/{self.branch}"
@pytest.mark.asyncio
async def test_truncates_upstream_error_body_in_failure_message(self):
client = AsyncMock()
client.get = AsyncMock(
side_effect=[
_make_mock_response(200, {"object": {"sha": "base-commit"}}),
_make_mock_response(200, {"tree": {"sha": "base-tree"}}),
_make_mock_response(200, {"tree": []}),
]
)
client.post = AsyncMock(
side_effect=[
_make_mock_response(201, {"sha": "blob1"}),
_make_mock_response(500, {}, text="x" * 500),
]
)
result = await self.backend.push_files(
self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client
)
assert result["status"] == "failed"
assert result["message"] == f"Failed to create tree: {'x' * 197}..."
class TestForgejoBackendApiBase:
def setup_method(self):
self.backend = ForgejoBackend()
def test_derives_api_base_from_repo_url(self):
result = self.backend.get_api_base("https://forgejo.example.com/owner/repo")
assert result == "https://forgejo.example.com/api/v1"
def test_derives_api_base_with_port(self):
result = self.backend.get_api_base("https://forgejo.example.com:3000/owner/repo")
assert result == "https://forgejo.example.com:3000/api/v1"
def test_invalid_url_raises_value_error(self):
with pytest.raises(ValueError, match="Cannot derive API base"):
self.backend.get_api_base("not-a-url")
def test_parse_url_uses_instance_host(self):
owner, repo = self.backend.parse_repo_url("https://forgejo.example.com/owner/repo")
assert owner == "owner"
assert repo == "repo"
class TestGitLabBackend:
def setup_method(self):
self.backend = GitLabBackend()
def test_parse_url_https(self):
owner, repo = self.backend.parse_repo_url("https://gitlab.com/owner/repo")
assert owner == "owner"
assert repo == "repo"
def test_parse_url_ssh(self):
owner, repo = self.backend.parse_repo_url("git@gitlab.com:owner/repo.git")
assert owner == "owner"
assert repo == "repo"
def test_parse_url_invalid_raises(self):
with pytest.raises(ValueError):
self.backend.parse_repo_url("not-a-url")
def test_get_api_base_derives_from_repo_url(self):
result = self.backend.get_api_base("https://gitlab.com/owner/repo")
assert result == "https://gitlab.com/api/v4"
def test_get_api_base_derives_from_self_hosted_url(self):
result = self.backend.get_api_base("https://my-gitlab.example.com/owner/repo")
assert result == "https://my-gitlab.example.com/api/v4"
def test_get_api_base_invalid_url_raises(self):
with pytest.raises(ValueError, match="Cannot derive API base"):
self.backend.get_api_base("not-a-url")
def test_get_headers_uses_bearer_token(self):
headers = self.backend.get_headers("mytoken")
assert headers["Authorization"] == "Bearer mytoken"
assert "Content-Type" in headers
def test_parse_url_subgroup_https(self):
namespace, repo = self.backend.parse_repo_url("https://gitlab.com/group/subgroup/project")
assert namespace == "group/subgroup"
assert repo == "project"
def test_parse_url_deep_namespace_https(self):
namespace, repo = self.backend.parse_repo_url("https://gitlab.com/myorg/team/api/backend")
assert namespace == "myorg/team/api"
assert repo == "backend"
def test_parse_url_subgroup_ssh(self):
namespace, repo = self.backend.parse_repo_url("git@gitlab.com:group/subgroup/project.git")
assert namespace == "group/subgroup"
assert repo == "project"
@pytest.mark.asyncio
async def test_push_files_encodes_subgroup_namespace_in_api_url(self):
backend = GitLabBackend()
repo_url = "https://gitlab.com/group/subgroup/project"
client = AsyncMock()
client.get = AsyncMock(
side_effect=[
_make_mock_response(200, {"name": "bambuddy-backup"}),
_make_mock_response(200, []),
]
)
client.post = AsyncMock(return_value=_make_mock_response(201, {"id": "abc123"}))
await backend.push_files(repo_url, "token", "bambuddy-backup", {"f.json": {}}, client)
called_url = client.get.call_args_list[0].args[0]
assert "group%2Fsubgroup%2Fproject" in called_url
def _blob_sha(content: dict) -> str:
content_bytes = json.dumps(content, indent=2, default=str).encode("utf-8")
return hashlib.sha1(f"blob {len(content_bytes)}\0".encode() + content_bytes, usedforsecurity=False).hexdigest()
def _make_mock_response(status_code: int, body=None, text: str = ""):
resp = MagicMock()
resp.status_code = status_code
resp.text = text
resp.json = MagicMock(return_value=body or {})
return resp
class TestGitLabBackendPushFiles:
def setup_method(self):
self.backend = GitLabBackend()
self.repo_url = "https://gitlab.com/owner/repo"
self.token = "glpat-test"
self.branch = "bambuddy-backup"
self.files = {"config/printers.json": {"name": "my-printer"}}
@pytest.mark.asyncio
async def test_skips_commit_when_content_unchanged(self):
sha = _blob_sha(self.files["config/printers.json"])
client = AsyncMock()
client.get = AsyncMock(
side_effect=[
# branch check → branch exists
_make_mock_response(200, {"name": self.branch}),
# tree page 1 → one blob whose sha matches current content
_make_mock_response(200, [{"type": "blob", "path": "config/printers.json", "id": sha}]),
# tree page 2 → empty, stop pagination
_make_mock_response(200, []),
]
)
result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
assert result["status"] == "skipped"
assert result["files_changed"] == 0
client.post.assert_not_called()
@pytest.mark.asyncio
async def test_commits_when_content_changed(self):
stale_sha = "0000000000000000000000000000000000000000"
client = AsyncMock()
client.get = AsyncMock(
side_effect=[
_make_mock_response(200, {"name": self.branch}),
_make_mock_response(200, [{"type": "blob", "path": "config/printers.json", "id": stale_sha}]),
_make_mock_response(200, []), # page 2 empty, stop pagination
]
)
client.post = AsyncMock(return_value=_make_mock_response(201, {"id": "abc123"}))
result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
assert result["status"] == "success"
assert result["files_changed"] == 1
client.post.assert_called_once()
@pytest.mark.asyncio
async def test_truncates_upstream_error_body_in_failure_message(self):
client = AsyncMock()
client.get = AsyncMock(
side_effect=[
_make_mock_response(200, {"name": self.branch}),
_make_mock_response(200, []),
]
)
client.post = AsyncMock(return_value=_make_mock_response(500, {}, text="x" * 500))
result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
assert result["status"] == "failed"
assert result["message"] == f"Failed to create commit: {'x' * 197}..."
@pytest.mark.asyncio
async def test_creates_new_file_not_in_existing_tree(self):
client = AsyncMock()
client.get = AsyncMock(
side_effect=[
_make_mock_response(200, {"name": self.branch}),
# tree is empty
_make_mock_response(200, []),
]
)
client.post = AsyncMock(return_value=_make_mock_response(201, {"id": "def456"}))
result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
assert result["status"] == "success"
call_kwargs = client.post.call_args.kwargs["json"]
assert call_kwargs["actions"][0]["action"] == "create"
@pytest.mark.asyncio
async def test_paginates_tree_to_find_unchanged_file_on_page_2(self):
"""Files beyond the first 100 are fetched; a file on page 2 is correctly skipped if unchanged."""
sha = _blob_sha(self.files["config/printers.json"])
page1_items = [{"type": "blob", "path": f"other{i}.json", "id": "aaa"} for i in range(100)]
page2_items = (
[{"type": "blob", "path": f"more{i}.json", "id": "bbb"} for i in range(19)]
+ [{"type": "blob", "path": "config/printers.json", "id": sha}]
) # 120 total blobs across two pages
client = AsyncMock()
client.get = AsyncMock(
side_effect=[
_make_mock_response(200, {"name": self.branch}), # branch check
_make_mock_response(200, page1_items), # tree page 1
_make_mock_response(200, page2_items), # tree page 2
_make_mock_response(200, []), # tree page 3 empty, stop
]
)
result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
assert result["status"] == "skipped"
client.post.assert_not_called()
@@ -0,0 +1,85 @@
"""Unit tests for GitHub backup Pydantic schemas."""
import pytest
from pydantic import ValidationError
from backend.app.schemas.github_backup import GitHubBackupConfigCreate, ProviderType
class TestProviderTypeEnum:
def test_has_expected_string_values(self):
assert ProviderType.GITHUB == "github"
assert ProviderType.GITEA == "gitea"
assert ProviderType.FORGEJO == "forgejo"
assert ProviderType.GITLAB == "gitlab"
class TestGitHubBackupConfigCreate:
BASE_FIELDS = {
"repository_url": "https://github.com/owner/repo",
"access_token": "ghp_token",
}
def test_plain_github_is_valid(self):
config = GitHubBackupConfigCreate(**self.BASE_FIELDS)
assert config.provider == ProviderType.GITHUB
def test_gitea_is_valid_without_api_base_url(self):
config = GitHubBackupConfigCreate(
repository_url="https://git.example.com/owner/repo",
access_token="token",
provider="gitea",
)
assert config.provider == ProviderType.GITEA
def test_forgejo_is_valid(self):
config = GitHubBackupConfigCreate(
repository_url="https://forgejo.example.com/owner/repo",
access_token="token",
provider="forgejo",
)
assert config.provider == ProviderType.FORGEJO
def test_url_regex_accepts_self_hosted_https_url(self):
config = GitHubBackupConfigCreate(
repository_url="https://git.example.com/owner/repo",
access_token="token",
provider="gitea",
)
assert "git.example.com" in config.repository_url
def test_url_regex_accepts_ssh_url(self):
config = GitHubBackupConfigCreate(
repository_url="git@github.com:owner/repo",
access_token="ghp_token",
)
assert config.repository_url == "git@github.com:owner/repo"
def test_invalid_url_raises_validation_error(self):
with pytest.raises(ValidationError, match="Invalid Git repository URL"):
GitHubBackupConfigCreate(
repository_url="not-a-url",
access_token="ghp_token",
)
def test_unknown_provider_raises_validation_error(self):
with pytest.raises(ValidationError):
GitHubBackupConfigCreate(
**self.BASE_FIELDS,
provider="not-a-provider",
)
def test_http_url_without_allow_insecure_raises(self):
with pytest.raises(ValidationError, match="Allow insecure HTTP"):
GitHubBackupConfigCreate(
repository_url="http://git.example.com/owner/repo",
access_token="token",
)
def test_http_url_with_allow_insecure_is_valid(self):
config = GitHubBackupConfigCreate(
repository_url="http://git.example.com/owner/repo",
access_token="token",
allow_insecure_http=True,
)
assert config.repository_url == "http://git.example.com/owner/repo"
@@ -0,0 +1,452 @@
/**
* Tests for the provider selection UI in GitHubBackupSettings.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { render } from '../utils';
import { GitHubBackupSettings } from '../../components/GitHubBackupSettings';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
const baseHandlers = () => [
http.get('/api/v1/github-backup/config', () => HttpResponse.json(null)),
http.get('/api/v1/github-backup/status', () =>
HttpResponse.json({
configured: false,
enabled: false,
is_running: false,
progress: null,
last_backup_at: null,
last_backup_status: null,
next_scheduled_run: null,
})
),
http.get('/api/v1/github-backup/logs', () => HttpResponse.json([])),
http.get('/api/v1/local-backup/status', () =>
HttpResponse.json({
enabled: false,
schedule: 'daily',
time: '03:00',
retention: 5,
path: '',
default_path: '/data/backups',
is_running: false,
last_backup_at: null,
last_status: null,
last_message: null,
next_run: null,
})
),
http.get('/api/v1/local-backup/backups', () => HttpResponse.json([])),
http.get('/api/v1/cloud/status', () => HttpResponse.json({ is_authenticated: false })),
http.get('/api/v1/printers', () => HttpResponse.json([])),
http.put('/api/v1/settings/', () => HttpResponse.json({})),
];
describe('GitHubBackupSettings - Provider Selection', () => {
beforeEach(() => {
vi.clearAllMocks();
server.use(...baseHandlers());
});
it('renders provider dropdown with GitHub selected by default', async () => {
render(<GitHubBackupSettings />);
await waitFor(() => {
expect(screen.getByText('Git Provider')).toBeInTheDocument();
});
const select = screen.getByRole('combobox', { name: /git provider/i });
expect(select).toHaveValue('github');
});
it('does not show instance URL field for any provider', async () => {
render(<GitHubBackupSettings />);
await waitFor(() => {
expect(screen.getByText('Git Provider')).toBeInTheDocument();
});
expect(screen.queryByText('Instance URL')).not.toBeInTheDocument();
});
it('loads provider from existing config', async () => {
server.use(
http.get('/api/v1/github-backup/config', () =>
HttpResponse.json({
id: 1,
repository_url: 'https://git.example.com/owner/repo',
has_token: true,
branch: 'main',
provider: 'gitea',
schedule_enabled: false,
schedule_type: 'daily',
backup_kprofiles: true,
backup_cloud_profiles: true,
backup_settings: false,
backup_spools: false,
backup_archives: false,
enabled: true,
last_backup_at: null,
last_backup_status: null,
last_backup_message: null,
last_backup_commit_sha: null,
next_scheduled_run: null,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
})
)
);
render(<GitHubBackupSettings />);
await waitFor(() => {
const select = screen.getByRole('combobox', { name: /git provider/i });
expect(select).toHaveValue('gitea');
});
});
it('renders Forgejo as a separate dropdown option', async () => {
render(<GitHubBackupSettings />);
await waitFor(() => {
expect(screen.getByRole('combobox', { name: /git provider/i })).toBeInTheDocument();
});
const select = screen.getByRole('combobox', { name: /git provider/i });
const options = Array.from((select as HTMLSelectElement).options).map((o) => o.value);
expect(options).toContain('gitea');
expect(options).toContain('forgejo');
});
it('loads forgejo provider from existing config', async () => {
server.use(
http.get('/api/v1/github-backup/config', () =>
HttpResponse.json({
id: 2,
repository_url: 'https://forgejo.example.com/owner/repo',
has_token: true,
branch: 'main',
provider: 'forgejo',
schedule_enabled: false,
schedule_type: 'daily',
backup_kprofiles: true,
backup_cloud_profiles: true,
backup_settings: false,
backup_spools: false,
backup_archives: false,
enabled: true,
last_backup_at: null,
last_backup_status: null,
last_backup_message: null,
last_backup_commit_sha: null,
next_scheduled_run: null,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
})
)
);
render(<GitHubBackupSettings />);
await waitFor(() => {
const select = screen.getByRole('combobox', { name: /git provider/i });
expect(select).toHaveValue('forgejo');
});
});
it('autosaves existing config changes after debounce', async () => {
let patchBody: Record<string, unknown> | null = null;
server.use(
http.get('/api/v1/github-backup/config', () =>
HttpResponse.json({
id: 3,
repository_url: 'https://git.example.com/owner/repo',
has_token: true,
branch: 'main',
provider: 'gitea',
allow_insecure_http: false,
schedule_enabled: false,
schedule_type: 'daily',
backup_kprofiles: true,
backup_cloud_profiles: true,
backup_settings: false,
backup_spools: false,
backup_archives: false,
enabled: true,
last_backup_at: null,
last_backup_status: null,
last_backup_message: null,
last_backup_commit_sha: null,
next_scheduled_run: null,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
})
),
http.patch('/api/v1/github-backup/config', async ({ request }) => {
patchBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({
id: 3,
repository_url: patchBody.repository_url,
has_token: true,
branch: patchBody.branch,
provider: patchBody.provider,
allow_insecure_http: patchBody.allow_insecure_http,
schedule_enabled: patchBody.schedule_enabled,
schedule_type: patchBody.schedule_type,
backup_kprofiles: patchBody.backup_kprofiles,
backup_cloud_profiles: patchBody.backup_cloud_profiles,
backup_settings: patchBody.backup_settings,
backup_spools: patchBody.backup_spools,
backup_archives: patchBody.backup_archives,
enabled: patchBody.enabled,
last_backup_at: null,
last_backup_status: null,
last_backup_message: null,
last_backup_commit_sha: null,
next_scheduled_run: null,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
});
})
);
render(<GitHubBackupSettings />);
const branchInput = await screen.findByDisplayValue('main');
await waitFor(() => expect(branchInput).toHaveValue('main'));
fireEvent.change(branchInput, { target: { value: 'dev' } });
await waitFor(() => {
expect(patchBody).toEqual({ branch: 'dev' });
}, { timeout: 2000 });
});
it('autosaves provider changes after debounce', async () => {
let patchBody: Record<string, unknown> | null = null;
server.use(
http.get('/api/v1/github-backup/config', () =>
HttpResponse.json({
id: 4,
repository_url: 'https://git.example.com/owner/repo',
has_token: true,
branch: 'main',
provider: 'gitea',
allow_insecure_http: false,
schedule_enabled: false,
schedule_type: 'daily',
backup_kprofiles: true,
backup_cloud_profiles: true,
backup_settings: false,
backup_spools: false,
backup_archives: false,
enabled: true,
last_backup_at: null,
last_backup_status: null,
last_backup_message: null,
last_backup_commit_sha: null,
next_scheduled_run: null,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
})
),
http.patch('/api/v1/github-backup/config', async ({ request }) => {
patchBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({
id: 4,
repository_url: patchBody.repository_url,
has_token: true,
branch: patchBody.branch,
provider: patchBody.provider,
allow_insecure_http: patchBody.allow_insecure_http,
schedule_enabled: patchBody.schedule_enabled,
schedule_type: patchBody.schedule_type,
backup_kprofiles: patchBody.backup_kprofiles,
backup_cloud_profiles: patchBody.backup_cloud_profiles,
backup_settings: patchBody.backup_settings,
backup_spools: patchBody.backup_spools,
backup_archives: patchBody.backup_archives,
enabled: patchBody.enabled,
last_backup_at: null,
last_backup_status: null,
last_backup_message: null,
last_backup_commit_sha: null,
next_scheduled_run: null,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
});
})
);
render(<GitHubBackupSettings />);
const providerSelect = await screen.findByRole('combobox', { name: /git provider/i });
await waitFor(() => expect(providerSelect).toHaveValue('gitea'));
fireEvent.change(providerSelect, { target: { value: 'forgejo' } });
await waitFor(() => {
expect(patchBody).toEqual({ provider: 'forgejo' });
}, { timeout: 2000 });
});
it('autosaves repository URL changes after debounce', async () => {
let patchBody: Record<string, unknown> | null = null;
server.use(
http.get('/api/v1/github-backup/config', () =>
HttpResponse.json({
id: 5,
repository_url: 'https://git.example.com/owner/repo',
has_token: true,
branch: 'main',
provider: 'gitea',
allow_insecure_http: false,
schedule_enabled: false,
schedule_type: 'daily',
backup_kprofiles: true,
backup_cloud_profiles: true,
backup_settings: false,
backup_spools: false,
backup_archives: false,
enabled: true,
last_backup_at: null,
last_backup_status: null,
last_backup_message: null,
last_backup_commit_sha: null,
next_scheduled_run: null,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
})
),
http.patch('/api/v1/github-backup/config', async ({ request }) => {
patchBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({
id: 5,
repository_url: patchBody.repository_url,
has_token: true,
branch: patchBody.branch,
provider: patchBody.provider,
allow_insecure_http: patchBody.allow_insecure_http,
schedule_enabled: patchBody.schedule_enabled,
schedule_type: patchBody.schedule_type,
backup_kprofiles: patchBody.backup_kprofiles,
backup_cloud_profiles: patchBody.backup_cloud_profiles,
backup_settings: patchBody.backup_settings,
backup_spools: patchBody.backup_spools,
backup_archives: patchBody.backup_archives,
enabled: patchBody.enabled,
last_backup_at: null,
last_backup_status: null,
last_backup_message: null,
last_backup_commit_sha: null,
next_scheduled_run: null,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
});
})
);
render(<GitHubBackupSettings />);
const repoInput = await screen.findByDisplayValue('https://git.example.com/owner/repo');
fireEvent.change(repoInput, { target: { value: 'https://git.example.com/owner/other-repo' } });
await waitFor(() => {
expect(patchBody).toEqual({
repository_url: 'https://git.example.com/owner/other-repo',
});
}, { timeout: 2000 });
});
it('does not let pending token autosave cancel provider settings autosave', async () => {
let patchBody: Record<string, unknown> | null = null;
let postBody: Record<string, unknown> | null = null;
server.use(
http.get('/api/v1/github-backup/config', () =>
HttpResponse.json({
id: 6,
repository_url: 'http://git.example.com/owner/repo',
has_token: true,
branch: 'main',
provider: 'gitea',
allow_insecure_http: true,
schedule_enabled: false,
schedule_type: 'daily',
backup_kprofiles: true,
backup_cloud_profiles: true,
backup_settings: false,
backup_spools: false,
backup_archives: false,
enabled: true,
last_backup_at: null,
last_backup_status: null,
last_backup_message: null,
last_backup_commit_sha: null,
next_scheduled_run: null,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
})
),
http.patch('/api/v1/github-backup/config', async ({ request }) => {
patchBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({
id: 6,
repository_url: 'http://git.example.com/owner/repo',
has_token: true,
branch: 'main',
provider: patchBody.provider ?? 'gitea',
allow_insecure_http: true,
schedule_enabled: false,
schedule_type: 'daily',
backup_kprofiles: true,
backup_cloud_profiles: true,
backup_settings: false,
backup_spools: false,
backup_archives: false,
enabled: true,
last_backup_at: null,
last_backup_status: null,
last_backup_message: null,
last_backup_commit_sha: null,
next_scheduled_run: null,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
});
}),
http.post('/api/v1/github-backup/config', async ({ request }) => {
postBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({
id: 6,
repository_url: postBody.repository_url,
has_token: true,
branch: postBody.branch,
provider: postBody.provider,
allow_insecure_http: postBody.allow_insecure_http,
schedule_enabled: postBody.schedule_enabled,
schedule_type: postBody.schedule_type,
backup_kprofiles: postBody.backup_kprofiles,
backup_cloud_profiles: postBody.backup_cloud_profiles,
backup_settings: postBody.backup_settings,
backup_spools: postBody.backup_spools,
backup_archives: postBody.backup_archives,
enabled: postBody.enabled,
last_backup_at: null,
last_backup_status: null,
last_backup_message: null,
last_backup_commit_sha: null,
next_scheduled_run: null,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
});
})
);
render(<GitHubBackupSettings />);
const tokenInput = await screen.findByPlaceholderText('Enter new token to update');
fireEvent.change(tokenInput, { target: { value: 'new-token' } });
const providerSelect = await screen.findByRole('combobox', { name: /git provider/i });
fireEvent.change(providerSelect, { target: { value: 'forgejo' } });
await waitFor(() => {
expect(patchBody).toEqual({ provider: 'forgejo' });
}, { timeout: 2000 });
});
});
+10 -3
View File
@@ -93,7 +93,9 @@ async function request<T>(
const detail = error.detail;
const message = typeof detail === 'string'
? detail
: (detail ? JSON.stringify(detail) : `HTTP ${response.status}`);
: Array.isArray(detail)
? detail.map((e: { msg?: string }) => (e.msg ?? '').replace(/^Value error,\s*/i, '')).filter(Boolean).join('; ')
: `HTTP ${response.status}`;
// Handle 401 Unauthorized - only clear token if it's actually invalid
// Don't clear on "Authentication required" which might be a timing issue
@@ -1974,12 +1976,15 @@ export interface NotificationProviderUpdate {
// GitHub Backup types
export type ScheduleType = 'hourly' | 'daily' | 'weekly';
export type GitProviderType = 'github' | 'gitea' | 'forgejo' | 'gitlab';
export interface GitHubBackupConfig {
id: number;
repository_url: string;
has_token: boolean;
branch: string;
provider: GitProviderType;
allow_insecure_http: boolean;
schedule_enabled: boolean;
schedule_type: ScheduleType;
backup_kprofiles: boolean;
@@ -2001,6 +2006,8 @@ export interface GitHubBackupConfigCreate {
repository_url: string;
access_token: string;
branch?: string;
provider?: GitProviderType;
allow_insecure_http?: boolean;
schedule_enabled?: boolean;
schedule_type?: ScheduleType;
backup_kprofiles?: boolean;
@@ -5084,9 +5091,9 @@ export const api = {
deleteGitHubBackupConfig: () =>
request<{ message: string }>('/github-backup/config', { method: 'DELETE' }),
testGitHubConnection: (repoUrl: string, token: string) =>
testGitHubConnection: (repoUrl: string, token: string, provider: GitProviderType = 'github') =>
request<GitHubTestConnectionResponse>(
`/github-backup/test?repo_url=${encodeURIComponent(repoUrl)}&token=${encodeURIComponent(token)}`,
`/github-backup/test?repo_url=${encodeURIComponent(repoUrl)}&token=${encodeURIComponent(token)}&provider=${encodeURIComponent(provider)}`,
{ method: 'POST' }
),
+191 -56
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
import {
@@ -27,6 +27,7 @@ import type {
GitHubBackupLog,
GitHubBackupStatus,
GitHubBackupTriggerResponse,
GitProviderType,
LocalBackupFile,
LocalBackupStatus,
ScheduleType,
@@ -76,6 +77,56 @@ function StatusBadge({ status }: StatusBadgeProps) {
);
}
const PROVIDER_REPO_URL_I18N_KEY: Record<GitProviderType, string> = {
github: 'backup.repoUrlPlaceholderGitHub',
gitea: 'backup.repoUrlPlaceholderGitea',
forgejo: 'backup.repoUrlPlaceholderForgejo',
gitlab: 'backup.repoUrlPlaceholderGitLab',
};
const PROVIDER_TOKEN_PLACEHOLDER: Record<GitProviderType, string> = {
github: 'ghp_xxxxxxxxxxxx',
gitea: 'your_access_token',
forgejo: 'your_access_token',
gitlab: 'glpat-xxxxxxxxxxxx',
};
interface GitHubBackupAutosaveState {
repository_url: string;
branch: string;
provider: GitProviderType;
allow_insecure_http: boolean;
schedule_enabled: boolean;
schedule_type: ScheduleType;
backup_kprofiles: boolean;
backup_cloud_profiles: boolean;
backup_settings: boolean;
backup_spools: boolean;
backup_archives: boolean;
enabled: boolean;
}
function serializeAutosaveState(state: GitHubBackupAutosaveState): string {
return JSON.stringify(state);
}
function getChangedAutosaveFields(
current: GitHubBackupAutosaveState,
previous: GitHubBackupAutosaveState | null
): Partial<GitHubBackupAutosaveState> {
if (!previous) {
return current;
}
const changes: Partial<GitHubBackupAutosaveState> = {};
for (const key of Object.keys(current) as Array<keyof GitHubBackupAutosaveState>) {
if (current[key] !== previous[key]) {
changes[key] = current[key] as never;
}
}
return changes;
}
export function GitHubBackupSettings() {
const queryClient = useQueryClient();
const { showToast } = useToast();
@@ -85,6 +136,7 @@ export function GitHubBackupSettings() {
const [repoUrl, setRepoUrl] = useState('');
const [accessToken, setAccessToken] = useState('');
const [branch, setBranch] = useState('main');
const [provider, setProvider] = useState<GitProviderType>('github');
const [scheduleEnabled, setScheduleEnabled] = useState(false);
const [scheduleType, setScheduleType] = useState<ScheduleType>('daily');
const [backupKProfiles, setBackupKProfiles] = useState(true);
@@ -92,6 +144,7 @@ export function GitHubBackupSettings() {
const [backupSettings, setBackupSettings] = useState(false);
const [backupSpools, setBackupSpools] = useState(false);
const [backupArchives, setBackupArchives] = useState(false);
const [allowInsecureHttp, setAllowInsecureHttp] = useState(false);
const [enabled, setEnabled] = useState(true);
// Local backup state
@@ -198,8 +251,32 @@ export function GitHubBackupSettings() {
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
// Auto-save debounce
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const isInitializedRef = useRef(false);
const settingsAutoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const tokenAutoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initializationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSavedAutosaveStateRef = useRef<GitHubBackupAutosaveState | null>(null);
const lastTokenScheduledForSaveRef = useRef('');
const [isInitialized, setIsInitialized] = useState(false);
const autoSaveState = useMemo<GitHubBackupAutosaveState>(() => ({
repository_url: repoUrl,
branch,
provider,
allow_insecure_http: allowInsecureHttp,
schedule_enabled: scheduleEnabled,
schedule_type: scheduleType,
backup_kprofiles: backupKProfiles,
backup_cloud_profiles: backupCloudProfiles,
backup_settings: backupSettings,
backup_spools: backupSpools,
backup_archives: backupArchives,
enabled,
}), [repoUrl, branch, provider, allowInsecureHttp, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, backupSpools, backupArchives, enabled]);
const autoSaveStateFingerprint = useMemo(
() => serializeAutosaveState(autoSaveState),
[autoSaveState]
);
// Queries
const { data: config, isLoading: configLoading } = useQuery<GitHubBackupConfig | null>({
@@ -251,9 +328,29 @@ export function GitHubBackupSettings() {
// Initialize form from config
useEffect(() => {
if (initializationTimerRef.current) {
clearTimeout(initializationTimerRef.current);
}
if (config) {
setIsInitialized(false);
lastSavedAutosaveStateRef.current = {
repository_url: config.repository_url,
branch: config.branch,
provider: config.provider ?? 'github',
allow_insecure_http: config.allow_insecure_http ?? false,
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,
};
setRepoUrl(config.repository_url);
setBranch(config.branch);
setProvider(config.provider ?? 'github');
setScheduleEnabled(config.schedule_enabled);
setScheduleType(config.schedule_type);
setBackupKProfiles(config.backup_kprofiles);
@@ -261,95 +358,103 @@ export function GitHubBackupSettings() {
setBackupSettings(config.backup_settings);
setBackupSpools(config.backup_spools);
setBackupArchives(config.backup_archives);
setAllowInsecureHttp(config.allow_insecure_http ?? false);
setEnabled(config.enabled);
setAccessToken(''); // Don't show stored token
// Mark as initialized after a tick to avoid auto-save on initial load
setTimeout(() => { isInitializedRef.current = true; }, 100);
initializationTimerRef.current = setTimeout(() => { setIsInitialized(true); }, 100);
} else {
setIsInitialized(false);
lastSavedAutosaveStateRef.current = null;
setAccessToken('');
}
return () => {
if (initializationTimerRef.current) {
clearTimeout(initializationTimerRef.current);
}
};
}, [config]);
// Auto-save function for existing configs
const autoSave = useCallback(async (includeToken: boolean = false) => {
if (!config?.has_token) return; // Only auto-save if config already exists
if (!config) return; // Only auto-save if config already exists
try {
if (includeToken && accessToken) {
// Full save with new token
await api.saveGitHubBackupConfig({
repository_url: repoUrl,
...autoSaveState,
access_token: accessToken,
branch,
schedule_enabled: scheduleEnabled,
schedule_type: scheduleType,
backup_kprofiles: backupKProfiles,
backup_cloud_profiles: backupCloudProfiles,
backup_settings: backupSettings,
backup_spools: backupSpools,
backup_archives: backupArchives,
enabled,
});
setAccessToken(''); // Clear after save
showToast(t('backup.tokenUpdated'));
lastTokenScheduledForSaveRef.current = '';
} else {
// Update without token
await api.updateGitHubBackupConfig({
repository_url: repoUrl,
branch,
schedule_enabled: scheduleEnabled,
schedule_type: scheduleType,
backup_kprofiles: backupKProfiles,
backup_cloud_profiles: backupCloudProfiles,
backup_settings: backupSettings,
backup_spools: backupSpools,
backup_archives: backupArchives,
enabled,
});
await api.updateGitHubBackupConfig(getChangedAutosaveFields(
autoSaveState,
lastSavedAutosaveStateRef.current
));
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');
}
}, [config?.has_token, repoUrl, accessToken, branch, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, backupSpools, backupArchives, enabled, queryClient, showToast, t]);
}, [config, accessToken, autoSaveState, queryClient, showToast, t]);
const autoSaveRef = useRef(autoSave);
useEffect(() => {
autoSaveRef.current = autoSave;
}, [autoSave]);
// Auto-save effect for existing configs (debounced)
useEffect(() => {
if (!isInitializedRef.current || !config?.has_token) return;
if (!isInitialized || !config) return;
if (
lastSavedAutosaveStateRef.current
&& autoSaveStateFingerprint === serializeAutosaveState(lastSavedAutosaveStateRef.current)
) return;
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
if (settingsAutoSaveTimerRef.current) {
clearTimeout(settingsAutoSaveTimerRef.current);
}
autoSaveTimerRef.current = setTimeout(() => {
settingsAutoSaveTimerRef.current = setTimeout(() => {
autoSave(false);
}, 500);
return () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
if (settingsAutoSaveTimerRef.current) {
clearTimeout(settingsAutoSaveTimerRef.current);
}
};
}, [repoUrl, branch, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, backupSpools, backupArchives, enabled, autoSave, config?.has_token]);
}, [isInitialized, config, autoSaveStateFingerprint, autoSave]);
// Auto-save token when it changes (with longer debounce)
useEffect(() => {
if (!isInitializedRef.current || !config?.has_token || !accessToken) return;
if (!isInitialized || !config || !accessToken) return;
if (accessToken === lastTokenScheduledForSaveRef.current) return;
lastTokenScheduledForSaveRef.current = accessToken;
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
if (tokenAutoSaveTimerRef.current) {
clearTimeout(tokenAutoSaveTimerRef.current);
}
autoSaveTimerRef.current = setTimeout(() => {
autoSave(true);
tokenAutoSaveTimerRef.current = setTimeout(() => {
autoSaveRef.current(true);
}, 1000);
return () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
if (tokenAutoSaveTimerRef.current) {
clearTimeout(tokenAutoSaveTimerRef.current);
}
};
}, [accessToken, autoSave, config?.has_token]);
}, [isInitialized, accessToken, config]);
// Mutations
const saveConfigMutation = useMutation({
@@ -359,7 +464,7 @@ export function GitHubBackupSettings() {
queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
showToast(t('backup.githubBackupEnabled'));
setAccessToken('');
isInitializedRef.current = true;
setIsInitialized(true);
},
onError: (error: Error) => {
showToast(t('backup.failedToSave', { message: error.message }), 'error');
@@ -409,7 +514,7 @@ export function GitHubBackupSettings() {
setTestLoading(false);
return;
}
result = await api.testGitHubConnection(repoUrl, accessToken);
result = await api.testGitHubConnection(repoUrl, accessToken, provider);
} else if (config?.has_token) {
// Use stored credentials
result = await api.testGitHubStoredConnection();
@@ -441,6 +546,8 @@ export function GitHubBackupSettings() {
repository_url: repoUrl,
access_token: accessToken,
branch,
provider,
allow_insecure_http: allowInsecureHttp,
schedule_enabled: scheduleEnabled,
schedule_type: scheduleType,
backup_kprofiles: backupKProfiles,
@@ -462,7 +569,7 @@ export function GitHubBackupSettings() {
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column - GitHub Backup */}
{/* Left Column - Git Backup */}
<div className="space-y-6">
<Card id="card-backup-github">
<CardHeader>
@@ -487,6 +594,22 @@ export function GitHubBackupSettings() {
{t('backup.githubDescription')}
</p>
{/* Provider Selection */}
<div>
<label htmlFor="git-provider-select" className="block text-sm text-bambu-gray mb-1">{t('backup.provider')}</label>
<select
id="git-provider-select"
value={provider}
onChange={(e) => { setProvider(e.target.value as GitProviderType); setTestResult(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>
<option value="gitlab">{t('backup.providerGitLab')}</option>
<option value="gitea">{t('backup.providerGitea')}</option>
<option value="forgejo">{t('backup.providerForgejo')}</option>
</select>
</div>
{/* Repository URL */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
@@ -496,9 +619,21 @@ export function GitHubBackupSettings() {
type="text"
value={repoUrl}
onChange={(e) => { setRepoUrl(e.target.value); setTestResult(null); }}
placeholder="https://github.com/username/bambuddy-backup"
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
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"
/>
<label className="flex items-start gap-2 mt-2 cursor-pointer">
<input
type="checkbox"
checked={allowInsecureHttp}
onChange={(e) => { setAllowInsecureHttp(e.target.checked); setTestResult(null); }}
className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<div>
<span className="text-sm text-white">{t('backup.allowInsecureHttp')}</span>
<p className="text-xs text-bambu-gray">{t('backup.allowInsecureHttpHint')}</p>
</div>
</label>
</div>
{/* Access Token */}
@@ -510,8 +645,8 @@ export function GitHubBackupSettings() {
type="password"
value={accessToken}
onChange={(e) => { setAccessToken(e.target.value); setTestResult(null); }}
placeholder={config?.has_token ? t('backup.enterNewToken') : 'ghp_xxxxxxxxxxxx'}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
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"
/>
<p className="text-xs text-bambu-gray mt-1">
{t('backup.tokenHint')}
@@ -527,7 +662,7 @@ export function GitHubBackupSettings() {
value={branch}
onChange={(e) => setBranch(e.target.value)}
placeholder="main"
className="w-full 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"
/>
</div>
<div>
@@ -542,7 +677,7 @@ export function GitHubBackupSettings() {
setScheduleType(e.target.value as ScheduleType);
}
}}
className="w-full 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="disabled">{t('backup.manualOnly')}</option>
<option value="hourly">{t('backup.hourly')}</option>
@@ -958,7 +1093,7 @@ export function GitHubBackupSettings() {
<label className="block text-sm text-bambu-gray mb-1">{t('backup.frequency')}</label>
<select
value={localBackupStatus?.schedule ?? 'daily'}
className="w-full 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"
onChange={async (e) => {
try {
await api.updateSettings({ local_backup_schedule: e.target.value });
@@ -980,7 +1115,7 @@ export function GitHubBackupSettings() {
<input
type="time"
value={localBackupStatus?.time ?? '03:00'}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none [color-scheme:dark]"
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 [color-scheme:dark]"
onChange={async (e) => {
try {
await api.updateSettings({ local_backup_time: e.target.value });
@@ -1001,7 +1136,7 @@ export function GitHubBackupSettings() {
min={1}
max={100}
value={localBackupStatus?.retention ?? 5}
className="w-full 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"
onChange={async (e) => {
const val = Math.max(1, Math.min(100, parseInt(e.target.value) || 5));
try {
@@ -1024,7 +1159,7 @@ export function GitHubBackupSettings() {
type="text"
value={localBackupPath}
onChange={(e) => setLocalBackupPath(e.target.value)}
className="w-full 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"
onBlur={async () => {
try {
await api.updateSettings({ local_backup_path: localBackupPath });
+14 -3
View File
@@ -3661,17 +3661,28 @@ export default {
restoreNote: 'Virtueller Drucker wird während der Wiederherstellung gestoppt',
// GitHub Backup
githubBackup: 'GitHub Backup',
githubBackup: 'Git Backup',
enabled: 'Aktiviert',
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.',
repositoryUrl: 'Repository-URL',
repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
repoUrlPlaceholderGitea: 'https://gitea.example.com/username/repo-name',
repoUrlPlaceholderForgejo: 'https://forgejo.example.com/username/repo-name',
repoUrlPlaceholderGitLab: 'https://gitlab.com/username/repo-name',
allowInsecureHttp: 'Unsicheres HTTP erlauben',
allowInsecureHttpHint: 'Für selbst gehostete Instanzen in privaten Netzwerken ohne TLS aktivieren',
personalAccessToken: 'Persönlicher Zugriffstoken',
tokenSaved: '(gespeichert)',
enterNewToken: 'Neuen Token eingeben zum Aktualisieren',
tokenHint: 'Feingranularer Token mit Lese-/Schreibberechtigung für Inhalte',
branch: 'Branch',
provider: 'Git Provider',
providerGitHub: 'GitHub',
providerGitLab: 'GitLab',
providerGitea: 'Gitea',
providerForgejo: 'Forgejo',
manualOnly: 'Nur manuell',
hourly: 'Stündlich',
daily: 'Täglich',
@@ -4813,9 +4824,9 @@ export default {
configureSlot: 'Slot konfigurieren',
},
// GitHub Backup Settings
// Git Backup Settings
githubBackup: {
title: 'GitHub-Backup',
title: 'Git-Backup',
history: 'Verlauf',
downloadBackup: 'Backup herunterladen',
restoreBackup: 'Backup wiederherstellen',
+14 -3
View File
@@ -3669,17 +3669,28 @@ export default {
restoreNote: 'Virtual Printer will be stopped during restore',
// GitHub Backup
githubBackup: 'GitHub Backup',
githubBackup: 'Git Backup',
enabled: 'Enabled',
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.',
repositoryUrl: 'Repository URL',
repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
repoUrlPlaceholderGitea: 'https://gitea.example.com/username/repo-name',
repoUrlPlaceholderForgejo: 'https://forgejo.example.com/username/repo-name',
repoUrlPlaceholderGitLab: 'https://gitlab.com/username/repo-name',
allowInsecureHttp: 'Allow insecure HTTP',
allowInsecureHttpHint: 'Enable for self-hosted instances on private networks without TLS',
personalAccessToken: 'Personal Access Token',
tokenSaved: '(saved)',
enterNewToken: 'Enter new token to update',
tokenHint: 'Fine-grained token with Contents read/write permission',
branch: 'Branch',
provider: 'Git Provider',
providerGitHub: 'GitHub',
providerGitLab: 'GitLab',
providerGitea: 'Gitea',
providerForgejo: 'Forgejo',
manualOnly: 'Manual only',
hourly: 'Hourly',
daily: 'Daily',
@@ -4822,9 +4833,9 @@ export default {
configureSlot: 'Configure Slot',
},
// GitHub Backup Settings
// Git Backup Settings
githubBackup: {
title: 'GitHub Backup',
title: 'Git Backup',
history: 'History',
downloadBackup: 'Download Backup',
restoreBackup: 'Restore Backup',
+13 -2
View File
@@ -3654,11 +3654,22 @@ export default {
cloudLoginRequiredShort: 'Connexion Cloud requise',
githubDescription: 'Synchronisez automatiquement vos profils vers un dépôt GitHub privé pour la sauvegarde et l\'historique des versions.',
repositoryUrl: 'URL du dépôt',
repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
repoUrlPlaceholderGitea: 'https://gitea.example.com/username/repo-name',
repoUrlPlaceholderForgejo: 'https://forgejo.example.com/username/repo-name',
repoUrlPlaceholderGitLab: 'https://gitlab.com/username/repo-name',
allowInsecureHttp: 'Autoriser HTTP non sécurisé',
allowInsecureHttpHint: 'Activer pour les instances auto-hébergées sur des réseaux privés sans TLS',
personalAccessToken: 'Jeton d\'accès personnel',
tokenSaved: '(enregistré)',
enterNewToken: 'Entrez un nouveau jeton pour mettre à jour',
tokenHint: 'Jeton à granularité fine avec permission de lecture/écriture du contenu',
branch: 'Branche',
provider: 'Git Provider',
providerGitHub: 'GitHub',
providerGitLab: 'GitLab',
providerGitea: 'Gitea',
providerForgejo: 'Forgejo',
manualOnly: 'Manuel uniquement',
hourly: 'Toutes les heures',
daily: 'Quotidien',
@@ -4801,9 +4812,9 @@ export default {
configureSlot: 'Configurer le slot',
},
// GitHub Backup Settings
// Git Backup Settings
githubBackup: {
title: 'Sauvegarde GitHub',
title: 'Sauvegarde Git',
history: 'Historique',
downloadBackup: 'Télécharger',
restoreBackup: 'Restaurer',
+13 -2
View File
@@ -3653,11 +3653,22 @@ export default {
cloudLoginRequiredShort: 'Accesso Cloud richiesto',
githubDescription: 'Sincronizza automaticamente i tuoi profili con un repository GitHub privato per backup e cronologia delle versioni.',
repositoryUrl: 'URL del repository',
repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
repoUrlPlaceholderGitea: 'https://gitea.example.com/username/repo-name',
repoUrlPlaceholderForgejo: 'https://forgejo.example.com/username/repo-name',
repoUrlPlaceholderGitLab: 'https://gitlab.com/username/repo-name',
allowInsecureHttp: 'Consenti HTTP non sicuro',
allowInsecureHttpHint: 'Abilita per istanze self-hosted su reti private senza TLS',
personalAccessToken: 'Token di accesso personale',
tokenSaved: '(salvato)',
enterNewToken: 'Inserisci un nuovo token per aggiornare',
tokenHint: 'Token a grana fine con permesso di lettura/scrittura dei contenuti',
branch: 'Branch',
provider: 'Git Provider',
providerGitHub: 'GitHub',
providerGitLab: 'GitLab',
providerGitea: 'Gitea',
providerForgejo: 'Forgejo',
manualOnly: 'Solo manuale',
hourly: 'Ogni ora',
daily: 'Giornaliero',
@@ -4800,9 +4811,9 @@ export default {
configureSlot: 'Configura slot',
},
// GitHub Backup Settings
// Git Backup Settings
githubBackup: {
title: 'Backup GitHub',
title: 'Backup Git',
history: 'Cronologia',
downloadBackup: 'Scarica backup',
restoreBackup: 'Ripristina backup',
+13 -2
View File
@@ -3666,11 +3666,22 @@ export default {
cloudLoginRequiredShort: 'Cloudログインが必要',
githubDescription: 'プロファイルをプライベートGitHubリポジトリに自動的に同期し、バックアップとバージョン履歴を保持します。',
repositoryUrl: 'リポジトリURL',
repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
repoUrlPlaceholderGitea: 'https://gitea.example.com/username/repo-name',
repoUrlPlaceholderForgejo: 'https://forgejo.example.com/username/repo-name',
repoUrlPlaceholderGitLab: 'https://gitlab.com/username/repo-name',
allowInsecureHttp: '安全でないHTTPを許可',
allowInsecureHttpHint: 'TLSなしのプライベートネットワーク上のセルフホストインスタンスに対して有効化',
personalAccessToken: '個人アクセストークン',
tokenSaved: '(保存済み)',
enterNewToken: '新しいトークンを入力して更新',
tokenHint: 'Contents読み書き権限を持つきめ細かいトークン',
branch: 'ブランチ',
provider: 'Git Provider',
providerGitHub: 'GitHub',
providerGitLab: 'GitLab',
providerGitea: 'Gitea',
providerForgejo: 'Forgejo',
manualOnly: '手動のみ',
hourly: '毎時',
daily: '毎日',
@@ -4813,9 +4824,9 @@ export default {
configureSlot: 'スロットを設定',
},
// GitHub Backup Settings
// Git Backup Settings
githubBackup: {
title: 'GitHubバックアップ',
title: 'Gitバックアップ',
history: '履歴',
downloadBackup: 'バックアップをダウンロード',
restoreBackup: 'バックアップを復元',
+13 -2
View File
@@ -3653,11 +3653,22 @@ export default {
cloudLoginRequiredShort: 'Login Cloud necessário',
githubDescription: 'Sincronize automaticamente seus perfis com um repositório GitHub privado para backup e histórico de versões.',
repositoryUrl: 'URL do repositório',
repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
repoUrlPlaceholderGitea: 'https://gitea.example.com/username/repo-name',
repoUrlPlaceholderForgejo: 'https://forgejo.example.com/username/repo-name',
repoUrlPlaceholderGitLab: 'https://gitlab.com/username/repo-name',
allowInsecureHttp: 'Permitir HTTP inseguro',
allowInsecureHttpHint: 'Ativar para instâncias auto-hospedadas em redes privadas sem TLS',
personalAccessToken: 'Token de acesso pessoal',
tokenSaved: '(salvo)',
enterNewToken: 'Digite um novo token para atualizar',
tokenHint: 'Token de granularidade fina com permissão de leitura/escrita de conteúdo',
branch: 'Branch',
provider: 'Git Provider',
providerGitHub: 'GitHub',
providerGitLab: 'GitLab',
providerGitea: 'Gitea',
providerForgejo: 'Forgejo',
manualOnly: 'Apenas manual',
hourly: 'A cada hora',
daily: 'Diário',
@@ -4800,9 +4811,9 @@ export default {
configureSlot: 'Configurar Slot',
},
// GitHub Backup Settings
// Git Backup Settings
githubBackup: {
title: 'Backup do GitHub',
title: 'Backup do Git',
history: 'Histórico',
downloadBackup: 'Baixar Backup',
restoreBackup: 'Restaurar Backup',
+13 -2
View File
@@ -3654,11 +3654,22 @@ export default {
cloudLoginRequiredShort: '需要Cloud登录',
githubDescription: '自动将您的配置文件同步到私有 GitHub 仓库以进行备份和版本历史记录。',
repositoryUrl: '仓库 URL',
repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
repoUrlPlaceholderGitea: 'https://gitea.example.com/username/repo-name',
repoUrlPlaceholderForgejo: 'https://forgejo.example.com/username/repo-name',
repoUrlPlaceholderGitLab: 'https://gitlab.com/username/repo-name',
allowInsecureHttp: '允许不安全的 HTTP',
allowInsecureHttpHint: '为无 TLS 的私有网络自托管实例启用',
personalAccessToken: '个人访问令牌',
tokenSaved: '(已保存)',
enterNewToken: '输入新令牌以更新',
tokenHint: '具有内容读写权限的细粒度令牌',
branch: '分支',
provider: 'Git Provider',
providerGitHub: 'GitHub',
providerGitLab: 'GitLab',
providerGitea: 'Gitea',
providerForgejo: 'Forgejo',
manualOnly: '仅手动',
hourly: '每小时',
daily: '每天',
@@ -4800,9 +4811,9 @@ export default {
configureSlot: '配置槽位',
},
// GitHub Backup Settings
// Git Backup Settings
githubBackup: {
title: 'GitHub 备份',
title: 'Git 备份',
history: '历史',
downloadBackup: '下载备份',
restoreBackup: '恢复备份',
+13 -2
View File
@@ -3654,11 +3654,22 @@ export default {
cloudLoginRequiredShort: '需要雲端登入',
githubDescription: '自動將您的設定檔案同步到私有 GitHub 倉庫以進行備份和版本歷史紀錄。',
repositoryUrl: '倉庫 URL',
repoUrlPlaceholderGitHub: 'https://github.com/username/repo-name',
repoUrlPlaceholderGitea: 'https://gitea.example.com/username/repo-name',
repoUrlPlaceholderForgejo: 'https://forgejo.example.com/username/repo-name',
repoUrlPlaceholderGitLab: 'https://gitlab.com/username/repo-name',
allowInsecureHttp: '允許不安全的 HTTP',
allowInsecureHttpHint: '為無 TLS 的私人網路自托管執行個體啟用',
personalAccessToken: '個人存取權杖',
tokenSaved: '(已儲存)',
enterNewToken: '輸入新權杖以更新',
tokenHint: '具有內容讀寫權限的細粒度權杖',
branch: '分支',
provider: 'Git Provider',
providerGitHub: 'GitHub',
providerGitLab: 'GitLab',
providerGitea: 'Gitea',
providerForgejo: 'Forgejo',
manualOnly: '僅手動',
hourly: '每小時',
daily: '每天',
@@ -4800,9 +4811,9 @@ export default {
configureSlot: '設定槽位',
},
// GitHub Backup Settings
// Git Backup Settings
githubBackup: {
title: 'GitHub 備份',
title: 'Git 備份',
history: '歷史',
downloadBackup: '下載備份',
restoreBackup: '恢復備份',