diff --git a/backend/app/api/routes/github_backup.py b/backend/app/api/routes/github_backup.py index a43f3edbe..0156a2ac8 100644 --- a/backend/app/api/routes/github_backup.py +++ b/backend/app/api/routes/github_backup.py @@ -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) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 4787e67b9..ab29bc523 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -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"), diff --git a/backend/app/models/github_backup.py b/backend/app/models/github_backup.py index f07114166..b6b2f21b5 100644 --- a/backend/app/models/github_backup.py +++ b/backend/app/models/github_backup.py @@ -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) diff --git a/backend/app/schemas/github_backup.py b/backend/app/schemas/github_backup.py index 01932dbd0..73c63d089 100644 --- a/backend/app/schemas/github_backup.py +++ b/backend/app/schemas/github_backup.py @@ -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 diff --git a/backend/app/services/git_providers/__init__.py b/backend/app/services/git_providers/__init__.py new file mode 100644 index 000000000..071727c83 --- /dev/null +++ b/backend/app/services/git_providers/__init__.py @@ -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"] diff --git a/backend/app/services/git_providers/base.py b/backend/app/services/git_providers/base.py new file mode 100644 index 000000000..020802ad1 --- /dev/null +++ b/backend/app/services/git_providers/base.py @@ -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.""" diff --git a/backend/app/services/git_providers/factory.py b/backend/app/services/git_providers/factory.py new file mode 100644 index 000000000..c68926ae9 --- /dev/null +++ b/backend/app/services/git_providers/factory.py @@ -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() diff --git a/backend/app/services/git_providers/forgejo.py b/backend/app/services/git_providers/forgejo.py new file mode 100644 index 000000000..e9bd8cbbb --- /dev/null +++ b/backend/app/services/git_providers/forgejo.py @@ -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. + """ diff --git a/backend/app/services/git_providers/gitea.py b/backend/app/services/git_providers/gitea.py new file mode 100644 index 000000000..e0e835341 --- /dev/null +++ b/backend/app/services/git_providers/gitea.py @@ -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 diff --git a/backend/app/services/git_providers/github.py b/backend/app/services/git_providers/github.py new file mode 100644 index 000000000..a9b9ff5a1 --- /dev/null +++ b/backend/app/services/git_providers/github.py @@ -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://[:]//[.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@:/[.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)} diff --git a/backend/app/services/git_providers/gitlab.py b/backend/app/services/git_providers/gitlab.py new file mode 100644 index 000000000..0569fcae9 --- /dev/null +++ b/backend/app/services/git_providers/gitlab.py @@ -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)} diff --git a/backend/app/services/github_backup.py b/backend/app/services/github_backup.py index d71f94185..aed3383d9 100644 --- a/backend/app/services/github_backup.py +++ b/backend/app/services/github_backup.py @@ -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: diff --git a/backend/tests/integration/test_github_backup_api.py b/backend/tests/integration/test_github_backup_api.py index dabf6cdc4..ba0672374 100644 --- a/backend/tests/integration/test_github_backup_api.py +++ b/backend/tests/integration/test_github_backup_api.py @@ -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): diff --git a/backend/tests/unit/test_git_providers.py b/backend/tests/unit/test_git_providers.py new file mode 100644 index 000000000..5b480a1be --- /dev/null +++ b/backend/tests/unit/test_git_providers.py @@ -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() diff --git a/backend/tests/unit/test_github_backup_schemas.py b/backend/tests/unit/test_github_backup_schemas.py new file mode 100644 index 000000000..48cb8e0bf --- /dev/null +++ b/backend/tests/unit/test_github_backup_schemas.py @@ -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" diff --git a/frontend/src/__tests__/components/GitHubBackupSettings.provider.test.tsx b/frontend/src/__tests__/components/GitHubBackupSettings.provider.test.tsx new file mode 100644 index 000000000..58a657e53 --- /dev/null +++ b/frontend/src/__tests__/components/GitHubBackupSettings.provider.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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 | 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; + 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(); + + 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 | 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; + 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(); + + 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 | 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; + 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(); + + 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 | null = null; + let postBody: Record | 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; + 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; + 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(); + + 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 }); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 532f3a426..16c58df71 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -93,7 +93,9 @@ async function request( 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( - `/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' } ), diff --git a/frontend/src/components/GitHubBackupSettings.tsx b/frontend/src/components/GitHubBackupSettings.tsx index 569725486..365516bbc 100644 --- a/frontend/src/components/GitHubBackupSettings.tsx +++ b/frontend/src/components/GitHubBackupSettings.tsx @@ -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 = { + github: 'backup.repoUrlPlaceholderGitHub', + gitea: 'backup.repoUrlPlaceholderGitea', + forgejo: 'backup.repoUrlPlaceholderForgejo', + gitlab: 'backup.repoUrlPlaceholderGitLab', +}; + +const PROVIDER_TOKEN_PLACEHOLDER: Record = { + 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 { + if (!previous) { + return current; + } + + const changes: Partial = {}; + for (const key of Object.keys(current) as Array) { + 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('github'); const [scheduleEnabled, setScheduleEnabled] = useState(false); const [scheduleType, setScheduleType] = useState('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(null); - const isInitializedRef = useRef(false); + const settingsAutoSaveTimerRef = useRef | null>(null); + const tokenAutoSaveTimerRef = useRef | null>(null); + const initializationTimerRef = useRef | null>(null); + const lastSavedAutosaveStateRef = useRef(null); + const lastTokenScheduledForSaveRef = useRef(''); + const [isInitialized, setIsInitialized] = useState(false); + + const autoSaveState = useMemo(() => ({ + 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({ @@ -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 (
- {/* Left Column - GitHub Backup */} + {/* Left Column - Git Backup */}
@@ -487,6 +594,22 @@ export function GitHubBackupSettings() { {t('backup.githubDescription')}

+ {/* Provider Selection */} +
+ + +
+ {/* Repository URL */}
{/* 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" />

{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" />

@@ -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" > @@ -958,7 +1093,7 @@ export function GitHubBackupSettings() { { 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 }); diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index c95a1aa3e..27f515dfe 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -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', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index d4daa6d4e..68b69988a 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index e0067043a..8503edb74 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -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', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 93a5b4837..90ddce365 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -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', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 22c93fd5d..aab12716b 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -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: 'バックアップを復元', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 939122264..248df8b5b 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -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', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index ed38d03fa..21669b418 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -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: '恢复备份', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 75d13138d..491d488df 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -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: '恢復備份',