Преглед изворни кода

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

BurntOutHylian пре 3 недеља
родитељ
комит
e45f967616

+ 31 - 8
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)
 
 

+ 6 - 0
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"),

+ 2 - 0
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)

+ 53 - 26
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

+ 4 - 0
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"]

+ 56 - 0
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."""

+ 22 - 0
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()

+ 11 - 0
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.
+    """

+ 44 - 0
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

+ 335 - 0
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://<host>[:<port>]/<owner>/<repo>[.git][/]
+        match = re.match(
+            r"https://[\w.\-]+(:\d+)?/([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?/?$",
+            url,
+        )
+        if match:
+            return match.group(2), match.group(3).removesuffix(".git")
+
+        # SSH: git@<host>:<owner>/<repo>[.git]
+        match = re.match(
+            r"git@[\w.\-]+:([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?$",
+            url,
+        )
+        if match:
+            return match.group(1), match.group(2).removesuffix(".git")
+
+        raise ValueError(f"Cannot parse repository URL: {url}")
+
+    async def test_connection(self, repo_url: str, token: str, client: httpx.AsyncClient) -> dict:
+        """Test API access and push permission for the repository."""
+        try:
+            owner, repo = self.parse_repo_url(repo_url)
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+
+            response = await client.get(f"{api_base}/repos/{owner}/{repo}", headers=headers)
+
+            if response.status_code == 401:
+                return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
+
+            if response.status_code == 404:
+                return {
+                    "success": False,
+                    "message": "Repository not found. Check URL and token permissions.",
+                    "repo_name": None,
+                    "permissions": None,
+                }
+
+            if response.status_code != 200:
+                return {
+                    "success": False,
+                    "message": f"API error: {response.status_code}",
+                    "repo_name": None,
+                    "permissions": None,
+                }
+
+            data = response.json()
+            permissions = data.get("permissions", {})
+
+            if not permissions.get("push", False):
+                return {
+                    "success": False,
+                    "message": "Token does not have push permission to this repository",
+                    "repo_name": data.get("full_name"),
+                    "permissions": permissions,
+                }
+
+            return {
+                "success": True,
+                "message": "Connection successful",
+                "repo_name": data.get("full_name"),
+                "permissions": permissions,
+            }
+
+        except Exception as e:
+            logger.error("Git connection test failed: %s", e)
+            return {
+                "success": False,
+                "message": f"Connection failed: {type(e).__name__}",
+                "repo_name": None,
+                "permissions": None,
+            }
+
+    async def push_files(
+        self,
+        repo_url: str,
+        token: str,
+        branch: str,
+        files: dict,
+        client: httpx.AsyncClient,
+    ) -> dict:
+        """Push files to the repository using the Git Data API."""
+        try:
+            owner, repo = self.parse_repo_url(repo_url)
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+
+            ref_response = await client.get(f"{api_base}/repos/{owner}/{repo}/git/refs/heads/{branch}", headers=headers)
+
+            if ref_response.status_code == 404:
+                return await self._create_branch_and_push(
+                    client, headers, api_base, owner, repo, branch, files, repo_url, token
+                )
+
+            if ref_response.status_code != 200:
+                return {
+                    "status": "failed",
+                    "message": f"Failed to get branch ref: {ref_response.status_code}",
+                    "error": self._truncated_response_text(ref_response),
+                }
+
+            current_commit_sha = ref_response.json()["object"]["sha"]
+
+            commit_response = await client.get(
+                f"{api_base}/repos/{owner}/{repo}/git/commits/{current_commit_sha}", headers=headers
+            )
+            if commit_response.status_code != 200:
+                return {"status": "failed", "message": "Failed to get current commit"}
+
+            current_tree_sha = commit_response.json()["tree"]["sha"]
+
+            tree_response = await client.get(
+                f"{api_base}/repos/{owner}/{repo}/git/trees/{current_tree_sha}?recursive=1", headers=headers
+            )
+            existing_files: dict[str, str] = {}
+            if tree_response.status_code == 200:
+                for item in tree_response.json().get("tree", []):
+                    if item["type"] == "blob":
+                        existing_files[item["path"]] = item["sha"]
+
+            tree_items = []
+            files_changed = 0
+
+            for path, content in files.items():
+                content_str = json.dumps(content, indent=2, default=str)
+                content_bytes = content_str.encode("utf-8")
+                content_sha = self._blob_sha(content_bytes)
+
+                if path in existing_files and existing_files[path] == content_sha:
+                    continue
+
+                blob_response = await client.post(
+                    f"{api_base}/repos/{owner}/{repo}/git/blobs",
+                    headers=headers,
+                    json={"content": base64.b64encode(content_bytes).decode(), "encoding": "base64"},
+                )
+                if blob_response.status_code != 201:
+                    logger.error("Failed to create blob for %s: %s", path, self._truncated_response_text(blob_response))
+                    continue
+
+                tree_items.append({"path": path, "mode": "100644", "type": "blob", "sha": blob_response.json()["sha"]})
+                files_changed += 1
+
+            if not tree_items:
+                return {"status": "skipped", "message": "No changes to commit", "commit_sha": None, "files_changed": 0}
+
+            tree_response = await client.post(
+                f"{api_base}/repos/{owner}/{repo}/git/trees",
+                headers=headers,
+                json={"base_tree": current_tree_sha, "tree": tree_items},
+            )
+            if tree_response.status_code != 201:
+                return {
+                    "status": "failed",
+                    "message": f"Failed to create tree: {self._truncated_response_text(tree_response)}",
+                }
+
+            new_tree_sha = tree_response.json()["sha"]
+            commit_message = f"Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}"
+            commit_response = await client.post(
+                f"{api_base}/repos/{owner}/{repo}/git/commits",
+                headers=headers,
+                json={"message": commit_message, "tree": new_tree_sha, "parents": [current_commit_sha]},
+            )
+            if commit_response.status_code != 201:
+                return {
+                    "status": "failed",
+                    "message": f"Failed to create commit: {self._truncated_response_text(commit_response)}",
+                }
+
+            new_commit_sha = commit_response.json()["sha"]
+
+            ref_update = await client.patch(
+                f"{api_base}/repos/{owner}/{repo}/git/refs/heads/{branch}",
+                headers=headers,
+                json={"sha": new_commit_sha},
+            )
+            if ref_update.status_code != 200:
+                return {
+                    "status": "failed",
+                    "message": f"Failed to update branch: {self._truncated_response_text(ref_update)}",
+                }
+
+            return {
+                "status": "success",
+                "message": f"Backup successful - {files_changed} files updated",
+                "commit_sha": new_commit_sha,
+                "files_changed": files_changed,
+            }
+
+        except Exception as e:
+            logger.error("Push to Git failed: %s", e)
+            return {"status": "failed", "message": str(e), "error": str(e)}
+
+    async def _create_branch_and_push(
+        self,
+        client: httpx.AsyncClient,
+        headers: dict,
+        api_base: str,
+        owner: str,
+        repo: str,
+        branch: str,
+        files: dict,
+        repo_url: str,
+        token: str,
+    ) -> dict:
+        """Create branch (from default branch or as initial commit) then push."""
+        try:
+            repo_response = await client.get(f"{api_base}/repos/{owner}/{repo}", headers=headers)
+            if repo_response.status_code != 200:
+                return {"status": "failed", "message": "Failed to get repo info"}
+
+            default_branch = repo_response.json().get("default_branch", "main")
+
+            ref_response = await client.get(
+                f"{api_base}/repos/{owner}/{repo}/git/refs/heads/{default_branch}", headers=headers
+            )
+            if ref_response.status_code != 200:
+                return await self._create_initial_commit(client, headers, api_base, owner, repo, branch, files)
+
+            base_sha = ref_response.json()["object"]["sha"]
+
+            create_ref = await client.post(
+                f"{api_base}/repos/{owner}/{repo}/git/refs",
+                headers=headers,
+                json={"ref": f"refs/heads/{branch}", "sha": base_sha},
+            )
+            if create_ref.status_code != 201:
+                return {
+                    "status": "failed",
+                    "message": f"Failed to create branch: {self._truncated_response_text(create_ref)}",
+                }
+
+            return await self.push_files(repo_url, token, branch, files, client)
+
+        except Exception as e:
+            return {"status": "failed", "message": str(e)}
+
+    async def _create_initial_commit(
+        self,
+        client: httpx.AsyncClient,
+        headers: dict,
+        api_base: str,
+        owner: str,
+        repo: str,
+        branch: str,
+        files: dict,
+    ) -> dict:
+        """Create the first commit in an empty repository."""
+        try:
+            tree_items = []
+            for path, content in files.items():
+                content_str = json.dumps(content, indent=2, default=str)
+                blob_response = await client.post(
+                    f"{api_base}/repos/{owner}/{repo}/git/blobs",
+                    headers=headers,
+                    json={"content": base64.b64encode(content_str.encode()).decode(), "encoding": "base64"},
+                )
+                if blob_response.status_code == 201:
+                    tree_items.append(
+                        {"path": path, "mode": "100644", "type": "blob", "sha": blob_response.json()["sha"]}
+                    )
+
+            tree_response = await client.post(
+                f"{api_base}/repos/{owner}/{repo}/git/trees",
+                headers=headers,
+                json={"tree": tree_items},
+            )
+            if tree_response.status_code != 201:
+                return {"status": "failed", "message": "Failed to create tree"}
+
+            tree_sha = tree_response.json()["sha"]
+            commit_response = await client.post(
+                f"{api_base}/repos/{owner}/{repo}/git/commits",
+                headers=headers,
+                json={
+                    "message": f"Initial Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}",
+                    "tree": tree_sha,
+                },
+            )
+            if commit_response.status_code != 201:
+                return {"status": "failed", "message": "Failed to create commit"}
+
+            commit_sha = commit_response.json()["sha"]
+            ref_response = await client.post(
+                f"{api_base}/repos/{owner}/{repo}/git/refs",
+                headers=headers,
+                json={"ref": f"refs/heads/{branch}", "sha": commit_sha},
+            )
+            if ref_response.status_code != 201:
+                return {"status": "failed", "message": "Failed to create branch ref"}
+
+            return {
+                "status": "success",
+                "message": f"Initial backup created - {len(files)} files",
+                "commit_sha": commit_sha,
+                "files_changed": len(files),
+            }
+
+        except Exception as e:
+            return {"status": "failed", "message": str(e)}

+ 257 - 0
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)}

+ 31 - 349
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:

+ 35 - 0
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):

+ 460 - 0
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()

+ 85 - 0
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"

+ 452 - 0
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(<GitHubBackupSettings />);
+    await waitFor(() => {
+      expect(screen.getByText('Git Provider')).toBeInTheDocument();
+    });
+    const select = screen.getByRole('combobox', { name: /git provider/i });
+    expect(select).toHaveValue('github');
+  });
+
+  it('does not show instance URL field for any provider', async () => {
+    render(<GitHubBackupSettings />);
+    await waitFor(() => {
+      expect(screen.getByText('Git Provider')).toBeInTheDocument();
+    });
+    expect(screen.queryByText('Instance URL')).not.toBeInTheDocument();
+  });
+
+  it('loads provider from existing config', async () => {
+    server.use(
+      http.get('/api/v1/github-backup/config', () =>
+        HttpResponse.json({
+          id: 1,
+          repository_url: 'https://git.example.com/owner/repo',
+          has_token: true,
+          branch: 'main',
+          provider: 'gitea',
+          schedule_enabled: false,
+          schedule_type: 'daily',
+          backup_kprofiles: true,
+          backup_cloud_profiles: true,
+          backup_settings: false,
+          backup_spools: false,
+          backup_archives: false,
+          enabled: true,
+          last_backup_at: null,
+          last_backup_status: null,
+          last_backup_message: null,
+          last_backup_commit_sha: null,
+          next_scheduled_run: null,
+          created_at: '2024-01-01T00:00:00Z',
+          updated_at: '2024-01-01T00:00:00Z',
+        })
+      )
+    );
+    render(<GitHubBackupSettings />);
+    await waitFor(() => {
+      const select = screen.getByRole('combobox', { name: /git provider/i });
+      expect(select).toHaveValue('gitea');
+    });
+  });
+
+  it('renders Forgejo as a separate dropdown option', async () => {
+    render(<GitHubBackupSettings />);
+    await waitFor(() => {
+      expect(screen.getByRole('combobox', { name: /git provider/i })).toBeInTheDocument();
+    });
+    const select = screen.getByRole('combobox', { name: /git provider/i });
+    const options = Array.from((select as HTMLSelectElement).options).map((o) => o.value);
+    expect(options).toContain('gitea');
+    expect(options).toContain('forgejo');
+  });
+
+  it('loads forgejo provider from existing config', async () => {
+    server.use(
+      http.get('/api/v1/github-backup/config', () =>
+        HttpResponse.json({
+          id: 2,
+          repository_url: 'https://forgejo.example.com/owner/repo',
+          has_token: true,
+          branch: 'main',
+          provider: 'forgejo',
+          schedule_enabled: false,
+          schedule_type: 'daily',
+          backup_kprofiles: true,
+          backup_cloud_profiles: true,
+          backup_settings: false,
+          backup_spools: false,
+          backup_archives: false,
+          enabled: true,
+          last_backup_at: null,
+          last_backup_status: null,
+          last_backup_message: null,
+          last_backup_commit_sha: null,
+          next_scheduled_run: null,
+          created_at: '2024-01-01T00:00:00Z',
+          updated_at: '2024-01-01T00:00:00Z',
+        })
+      )
+    );
+    render(<GitHubBackupSettings />);
+    await waitFor(() => {
+      const select = screen.getByRole('combobox', { name: /git provider/i });
+      expect(select).toHaveValue('forgejo');
+    });
+  });
+
+  it('autosaves existing config changes after debounce', async () => {
+    let patchBody: Record<string, unknown> | null = null;
+
+    server.use(
+      http.get('/api/v1/github-backup/config', () =>
+        HttpResponse.json({
+          id: 3,
+          repository_url: 'https://git.example.com/owner/repo',
+          has_token: true,
+          branch: 'main',
+          provider: 'gitea',
+          allow_insecure_http: false,
+          schedule_enabled: false,
+          schedule_type: 'daily',
+          backup_kprofiles: true,
+          backup_cloud_profiles: true,
+          backup_settings: false,
+          backup_spools: false,
+          backup_archives: false,
+          enabled: true,
+          last_backup_at: null,
+          last_backup_status: null,
+          last_backup_message: null,
+          last_backup_commit_sha: null,
+          next_scheduled_run: null,
+          created_at: '2024-01-01T00:00:00Z',
+          updated_at: '2024-01-01T00:00:00Z',
+        })
+      ),
+      http.patch('/api/v1/github-backup/config', async ({ request }) => {
+        patchBody = await request.json() as Record<string, unknown>;
+        return HttpResponse.json({
+          id: 3,
+          repository_url: patchBody.repository_url,
+          has_token: true,
+          branch: patchBody.branch,
+          provider: patchBody.provider,
+          allow_insecure_http: patchBody.allow_insecure_http,
+          schedule_enabled: patchBody.schedule_enabled,
+          schedule_type: patchBody.schedule_type,
+          backup_kprofiles: patchBody.backup_kprofiles,
+          backup_cloud_profiles: patchBody.backup_cloud_profiles,
+          backup_settings: patchBody.backup_settings,
+          backup_spools: patchBody.backup_spools,
+          backup_archives: patchBody.backup_archives,
+          enabled: patchBody.enabled,
+          last_backup_at: null,
+          last_backup_status: null,
+          last_backup_message: null,
+          last_backup_commit_sha: null,
+          next_scheduled_run: null,
+          created_at: '2024-01-01T00:00:00Z',
+          updated_at: '2024-01-01T00:00:00Z',
+        });
+      })
+    );
+
+    render(<GitHubBackupSettings />);
+
+    const branchInput = await screen.findByDisplayValue('main');
+    await waitFor(() => expect(branchInput).toHaveValue('main'));
+
+    fireEvent.change(branchInput, { target: { value: 'dev' } });
+
+    await waitFor(() => {
+      expect(patchBody).toEqual({ branch: 'dev' });
+    }, { timeout: 2000 });
+  });
+
+  it('autosaves provider changes after debounce', async () => {
+    let patchBody: Record<string, unknown> | null = null;
+
+    server.use(
+      http.get('/api/v1/github-backup/config', () =>
+        HttpResponse.json({
+          id: 4,
+          repository_url: 'https://git.example.com/owner/repo',
+          has_token: true,
+          branch: 'main',
+          provider: 'gitea',
+          allow_insecure_http: false,
+          schedule_enabled: false,
+          schedule_type: 'daily',
+          backup_kprofiles: true,
+          backup_cloud_profiles: true,
+          backup_settings: false,
+          backup_spools: false,
+          backup_archives: false,
+          enabled: true,
+          last_backup_at: null,
+          last_backup_status: null,
+          last_backup_message: null,
+          last_backup_commit_sha: null,
+          next_scheduled_run: null,
+          created_at: '2024-01-01T00:00:00Z',
+          updated_at: '2024-01-01T00:00:00Z',
+        })
+      ),
+      http.patch('/api/v1/github-backup/config', async ({ request }) => {
+        patchBody = await request.json() as Record<string, unknown>;
+        return HttpResponse.json({
+          id: 4,
+          repository_url: patchBody.repository_url,
+          has_token: true,
+          branch: patchBody.branch,
+          provider: patchBody.provider,
+          allow_insecure_http: patchBody.allow_insecure_http,
+          schedule_enabled: patchBody.schedule_enabled,
+          schedule_type: patchBody.schedule_type,
+          backup_kprofiles: patchBody.backup_kprofiles,
+          backup_cloud_profiles: patchBody.backup_cloud_profiles,
+          backup_settings: patchBody.backup_settings,
+          backup_spools: patchBody.backup_spools,
+          backup_archives: patchBody.backup_archives,
+          enabled: patchBody.enabled,
+          last_backup_at: null,
+          last_backup_status: null,
+          last_backup_message: null,
+          last_backup_commit_sha: null,
+          next_scheduled_run: null,
+          created_at: '2024-01-01T00:00:00Z',
+          updated_at: '2024-01-01T00:00:00Z',
+        });
+      })
+    );
+
+    render(<GitHubBackupSettings />);
+
+    const providerSelect = await screen.findByRole('combobox', { name: /git provider/i });
+    await waitFor(() => expect(providerSelect).toHaveValue('gitea'));
+
+    fireEvent.change(providerSelect, { target: { value: 'forgejo' } });
+
+    await waitFor(() => {
+      expect(patchBody).toEqual({ provider: 'forgejo' });
+    }, { timeout: 2000 });
+  });
+
+  it('autosaves repository URL changes after debounce', async () => {
+    let patchBody: Record<string, unknown> | null = null;
+
+    server.use(
+      http.get('/api/v1/github-backup/config', () =>
+        HttpResponse.json({
+          id: 5,
+          repository_url: 'https://git.example.com/owner/repo',
+          has_token: true,
+          branch: 'main',
+          provider: 'gitea',
+          allow_insecure_http: false,
+          schedule_enabled: false,
+          schedule_type: 'daily',
+          backup_kprofiles: true,
+          backup_cloud_profiles: true,
+          backup_settings: false,
+          backup_spools: false,
+          backup_archives: false,
+          enabled: true,
+          last_backup_at: null,
+          last_backup_status: null,
+          last_backup_message: null,
+          last_backup_commit_sha: null,
+          next_scheduled_run: null,
+          created_at: '2024-01-01T00:00:00Z',
+          updated_at: '2024-01-01T00:00:00Z',
+        })
+      ),
+      http.patch('/api/v1/github-backup/config', async ({ request }) => {
+        patchBody = await request.json() as Record<string, unknown>;
+        return HttpResponse.json({
+          id: 5,
+          repository_url: patchBody.repository_url,
+          has_token: true,
+          branch: patchBody.branch,
+          provider: patchBody.provider,
+          allow_insecure_http: patchBody.allow_insecure_http,
+          schedule_enabled: patchBody.schedule_enabled,
+          schedule_type: patchBody.schedule_type,
+          backup_kprofiles: patchBody.backup_kprofiles,
+          backup_cloud_profiles: patchBody.backup_cloud_profiles,
+          backup_settings: patchBody.backup_settings,
+          backup_spools: patchBody.backup_spools,
+          backup_archives: patchBody.backup_archives,
+          enabled: patchBody.enabled,
+          last_backup_at: null,
+          last_backup_status: null,
+          last_backup_message: null,
+          last_backup_commit_sha: null,
+          next_scheduled_run: null,
+          created_at: '2024-01-01T00:00:00Z',
+          updated_at: '2024-01-01T00:00:00Z',
+        });
+      })
+    );
+
+    render(<GitHubBackupSettings />);
+
+    const repoInput = await screen.findByDisplayValue('https://git.example.com/owner/repo');
+    fireEvent.change(repoInput, { target: { value: 'https://git.example.com/owner/other-repo' } });
+
+    await waitFor(() => {
+      expect(patchBody).toEqual({
+        repository_url: 'https://git.example.com/owner/other-repo',
+      });
+    }, { timeout: 2000 });
+  });
+
+  it('does not let pending token autosave cancel provider settings autosave', async () => {
+    let patchBody: Record<string, unknown> | null = null;
+    let postBody: Record<string, unknown> | null = null;
+
+    server.use(
+      http.get('/api/v1/github-backup/config', () =>
+        HttpResponse.json({
+          id: 6,
+          repository_url: 'http://git.example.com/owner/repo',
+          has_token: true,
+          branch: 'main',
+          provider: 'gitea',
+          allow_insecure_http: true,
+          schedule_enabled: false,
+          schedule_type: 'daily',
+          backup_kprofiles: true,
+          backup_cloud_profiles: true,
+          backup_settings: false,
+          backup_spools: false,
+          backup_archives: false,
+          enabled: true,
+          last_backup_at: null,
+          last_backup_status: null,
+          last_backup_message: null,
+          last_backup_commit_sha: null,
+          next_scheduled_run: null,
+          created_at: '2024-01-01T00:00:00Z',
+          updated_at: '2024-01-01T00:00:00Z',
+        })
+      ),
+      http.patch('/api/v1/github-backup/config', async ({ request }) => {
+        patchBody = await request.json() as Record<string, unknown>;
+        return HttpResponse.json({
+          id: 6,
+          repository_url: 'http://git.example.com/owner/repo',
+          has_token: true,
+          branch: 'main',
+          provider: patchBody.provider ?? 'gitea',
+          allow_insecure_http: true,
+          schedule_enabled: false,
+          schedule_type: 'daily',
+          backup_kprofiles: true,
+          backup_cloud_profiles: true,
+          backup_settings: false,
+          backup_spools: false,
+          backup_archives: false,
+          enabled: true,
+          last_backup_at: null,
+          last_backup_status: null,
+          last_backup_message: null,
+          last_backup_commit_sha: null,
+          next_scheduled_run: null,
+          created_at: '2024-01-01T00:00:00Z',
+          updated_at: '2024-01-01T00:00:00Z',
+        });
+      }),
+      http.post('/api/v1/github-backup/config', async ({ request }) => {
+        postBody = await request.json() as Record<string, unknown>;
+        return HttpResponse.json({
+          id: 6,
+          repository_url: postBody.repository_url,
+          has_token: true,
+          branch: postBody.branch,
+          provider: postBody.provider,
+          allow_insecure_http: postBody.allow_insecure_http,
+          schedule_enabled: postBody.schedule_enabled,
+          schedule_type: postBody.schedule_type,
+          backup_kprofiles: postBody.backup_kprofiles,
+          backup_cloud_profiles: postBody.backup_cloud_profiles,
+          backup_settings: postBody.backup_settings,
+          backup_spools: postBody.backup_spools,
+          backup_archives: postBody.backup_archives,
+          enabled: postBody.enabled,
+          last_backup_at: null,
+          last_backup_status: null,
+          last_backup_message: null,
+          last_backup_commit_sha: null,
+          next_scheduled_run: null,
+          created_at: '2024-01-01T00:00:00Z',
+          updated_at: '2024-01-01T00:00:00Z',
+        });
+      })
+    );
+
+    render(<GitHubBackupSettings />);
+
+    const tokenInput = await screen.findByPlaceholderText('Enter new token to update');
+    fireEvent.change(tokenInput, { target: { value: 'new-token' } });
+
+    const providerSelect = await screen.findByRole('combobox', { name: /git provider/i });
+    fireEvent.change(providerSelect, { target: { value: 'forgejo' } });
+
+    await waitFor(() => {
+      expect(patchBody).toEqual({ provider: 'forgejo' });
+    }, { timeout: 2000 });
+  });
+});

+ 10 - 3
frontend/src/api/client.ts

@@ -93,7 +93,9 @@ async function request<T>(
     const detail = error.detail;
     const message = typeof detail === 'string'
       ? detail
-      : (detail ? JSON.stringify(detail) : `HTTP ${response.status}`);
+      : Array.isArray(detail)
+        ? detail.map((e: { msg?: string }) => (e.msg ?? '').replace(/^Value error,\s*/i, '')).filter(Boolean).join('; ')
+        : `HTTP ${response.status}`;
 
     // Handle 401 Unauthorized - only clear token if it's actually invalid
     // Don't clear on "Authentication required" which might be a timing issue
@@ -1974,12 +1976,15 @@ export interface NotificationProviderUpdate {
 
 // GitHub Backup types
 export type ScheduleType = 'hourly' | 'daily' | 'weekly';
+export type GitProviderType = 'github' | 'gitea' | 'forgejo' | 'gitlab';
 
 export interface GitHubBackupConfig {
   id: number;
   repository_url: string;
   has_token: boolean;
   branch: string;
+  provider: GitProviderType;
+  allow_insecure_http: boolean;
   schedule_enabled: boolean;
   schedule_type: ScheduleType;
   backup_kprofiles: boolean;
@@ -2001,6 +2006,8 @@ export interface GitHubBackupConfigCreate {
   repository_url: string;
   access_token: string;
   branch?: string;
+  provider?: GitProviderType;
+  allow_insecure_http?: boolean;
   schedule_enabled?: boolean;
   schedule_type?: ScheduleType;
   backup_kprofiles?: boolean;
@@ -5084,9 +5091,9 @@ export const api = {
   deleteGitHubBackupConfig: () =>
     request<{ message: string }>('/github-backup/config', { method: 'DELETE' }),
 
-  testGitHubConnection: (repoUrl: string, token: string) =>
+  testGitHubConnection: (repoUrl: string, token: string, provider: GitProviderType = 'github') =>
     request<GitHubTestConnectionResponse>(
-      `/github-backup/test?repo_url=${encodeURIComponent(repoUrl)}&token=${encodeURIComponent(token)}`,
+      `/github-backup/test?repo_url=${encodeURIComponent(repoUrl)}&token=${encodeURIComponent(token)}&provider=${encodeURIComponent(provider)}`,
       { method: 'POST' }
     ),
 

+ 192 - 57
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<GitProviderType, string> = {
+  github: 'backup.repoUrlPlaceholderGitHub',
+  gitea: 'backup.repoUrlPlaceholderGitea',
+  forgejo: 'backup.repoUrlPlaceholderForgejo',
+  gitlab: 'backup.repoUrlPlaceholderGitLab',
+};
+
+const PROVIDER_TOKEN_PLACEHOLDER: Record<GitProviderType, string> = {
+  github: 'ghp_xxxxxxxxxxxx',
+  gitea: 'your_access_token',
+  forgejo: 'your_access_token',
+  gitlab: 'glpat-xxxxxxxxxxxx',
+};
+
+interface GitHubBackupAutosaveState {
+  repository_url: string;
+  branch: string;
+  provider: GitProviderType;
+  allow_insecure_http: boolean;
+  schedule_enabled: boolean;
+  schedule_type: ScheduleType;
+  backup_kprofiles: boolean;
+  backup_cloud_profiles: boolean;
+  backup_settings: boolean;
+  backup_spools: boolean;
+  backup_archives: boolean;
+  enabled: boolean;
+}
+
+function serializeAutosaveState(state: GitHubBackupAutosaveState): string {
+  return JSON.stringify(state);
+}
+
+function getChangedAutosaveFields(
+  current: GitHubBackupAutosaveState,
+  previous: GitHubBackupAutosaveState | null
+): Partial<GitHubBackupAutosaveState> {
+  if (!previous) {
+    return current;
+  }
+
+  const changes: Partial<GitHubBackupAutosaveState> = {};
+  for (const key of Object.keys(current) as Array<keyof GitHubBackupAutosaveState>) {
+    if (current[key] !== previous[key]) {
+      changes[key] = current[key] as never;
+    }
+  }
+  return changes;
+}
+
 export function GitHubBackupSettings() {
   const queryClient = useQueryClient();
   const { showToast } = useToast();
@@ -85,6 +136,7 @@ export function GitHubBackupSettings() {
   const [repoUrl, setRepoUrl] = useState('');
   const [accessToken, setAccessToken] = useState('');
   const [branch, setBranch] = useState('main');
+  const [provider, setProvider] = useState<GitProviderType>('github');
   const [scheduleEnabled, setScheduleEnabled] = useState(false);
   const [scheduleType, setScheduleType] = useState<ScheduleType>('daily');
   const [backupKProfiles, setBackupKProfiles] = useState(true);
@@ -92,6 +144,7 @@ export function GitHubBackupSettings() {
   const [backupSettings, setBackupSettings] = useState(false);
   const [backupSpools, setBackupSpools] = useState(false);
   const [backupArchives, setBackupArchives] = useState(false);
+  const [allowInsecureHttp, setAllowInsecureHttp] = useState(false);
   const [enabled, setEnabled] = useState(true);
 
   // Local backup state
@@ -198,8 +251,32 @@ export function GitHubBackupSettings() {
   const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
 
   // Auto-save debounce
-  const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
-  const isInitializedRef = useRef(false);
+  const settingsAutoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const tokenAutoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const initializationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const lastSavedAutosaveStateRef = useRef<GitHubBackupAutosaveState | null>(null);
+  const lastTokenScheduledForSaveRef = useRef('');
+  const [isInitialized, setIsInitialized] = useState(false);
+
+  const autoSaveState = useMemo<GitHubBackupAutosaveState>(() => ({
+    repository_url: repoUrl,
+    branch,
+    provider,
+    allow_insecure_http: allowInsecureHttp,
+    schedule_enabled: scheduleEnabled,
+    schedule_type: scheduleType,
+    backup_kprofiles: backupKProfiles,
+    backup_cloud_profiles: backupCloudProfiles,
+    backup_settings: backupSettings,
+    backup_spools: backupSpools,
+    backup_archives: backupArchives,
+    enabled,
+  }), [repoUrl, branch, provider, allowInsecureHttp, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, backupSpools, backupArchives, enabled]);
+
+  const autoSaveStateFingerprint = useMemo(
+    () => serializeAutosaveState(autoSaveState),
+    [autoSaveState]
+  );
 
   // Queries
   const { data: config, isLoading: configLoading } = useQuery<GitHubBackupConfig | null>({
@@ -251,9 +328,29 @@ export function GitHubBackupSettings() {
 
   // Initialize form from config
   useEffect(() => {
+    if (initializationTimerRef.current) {
+      clearTimeout(initializationTimerRef.current);
+    }
+
     if (config) {
+      setIsInitialized(false);
+      lastSavedAutosaveStateRef.current = {
+        repository_url: config.repository_url,
+        branch: config.branch,
+        provider: config.provider ?? 'github',
+        allow_insecure_http: config.allow_insecure_http ?? false,
+        schedule_enabled: config.schedule_enabled,
+        schedule_type: config.schedule_type,
+        backup_kprofiles: config.backup_kprofiles,
+        backup_cloud_profiles: config.backup_cloud_profiles,
+        backup_settings: config.backup_settings,
+        backup_spools: config.backup_spools,
+        backup_archives: config.backup_archives,
+        enabled: config.enabled,
+      };
       setRepoUrl(config.repository_url);
       setBranch(config.branch);
+      setProvider(config.provider ?? 'github');
       setScheduleEnabled(config.schedule_enabled);
       setScheduleType(config.schedule_type);
       setBackupKProfiles(config.backup_kprofiles);
@@ -261,95 +358,103 @@ export function GitHubBackupSettings() {
       setBackupSettings(config.backup_settings);
       setBackupSpools(config.backup_spools);
       setBackupArchives(config.backup_archives);
+      setAllowInsecureHttp(config.allow_insecure_http ?? false);
       setEnabled(config.enabled);
       setAccessToken(''); // Don't show stored token
       // Mark as initialized after a tick to avoid auto-save on initial load
-      setTimeout(() => { isInitializedRef.current = true; }, 100);
+      initializationTimerRef.current = setTimeout(() => { setIsInitialized(true); }, 100);
+    } else {
+      setIsInitialized(false);
+      lastSavedAutosaveStateRef.current = null;
+      setAccessToken('');
     }
+
+    return () => {
+      if (initializationTimerRef.current) {
+        clearTimeout(initializationTimerRef.current);
+      }
+    };
   }, [config]);
 
   // Auto-save function for existing configs
   const autoSave = useCallback(async (includeToken: boolean = false) => {
-    if (!config?.has_token) return; // Only auto-save if config already exists
+    if (!config) return; // Only auto-save if config already exists
 
     try {
       if (includeToken && accessToken) {
         // Full save with new token
         await api.saveGitHubBackupConfig({
-          repository_url: repoUrl,
+          ...autoSaveState,
           access_token: accessToken,
-          branch,
-          schedule_enabled: scheduleEnabled,
-          schedule_type: scheduleType,
-          backup_kprofiles: backupKProfiles,
-          backup_cloud_profiles: backupCloudProfiles,
-          backup_settings: backupSettings,
-          backup_spools: backupSpools,
-          backup_archives: backupArchives,
-          enabled,
         });
         setAccessToken(''); // Clear after save
         showToast(t('backup.tokenUpdated'));
+        lastTokenScheduledForSaveRef.current = '';
       } else {
         // Update without token
-        await api.updateGitHubBackupConfig({
-          repository_url: repoUrl,
-          branch,
-          schedule_enabled: scheduleEnabled,
-          schedule_type: scheduleType,
-          backup_kprofiles: backupKProfiles,
-          backup_cloud_profiles: backupCloudProfiles,
-          backup_settings: backupSettings,
-          backup_spools: backupSpools,
-          backup_archives: backupArchives,
-          enabled,
-        });
+        await api.updateGitHubBackupConfig(getChangedAutosaveFields(
+          autoSaveState,
+          lastSavedAutosaveStateRef.current
+        ));
         showToast(t('backup.settingsSaved'));
       }
+      lastSavedAutosaveStateRef.current = autoSaveState;
       queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
       queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
     } catch (error) {
       showToast(t('backup.failedToSave', { message: (error as Error).message }), 'error');
     }
-  }, [config?.has_token, repoUrl, accessToken, branch, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, backupSpools, backupArchives, enabled, queryClient, showToast, t]);
+  }, [config, accessToken, autoSaveState, queryClient, showToast, t]);
+
+  const autoSaveRef = useRef(autoSave);
 
-  // Auto-save effect for existing configs (debounced)
   useEffect(() => {
-    if (!isInitializedRef.current || !config?.has_token) return;
+    autoSaveRef.current = autoSave;
+  }, [autoSave]);
 
-    if (autoSaveTimerRef.current) {
-      clearTimeout(autoSaveTimerRef.current);
+  // Auto-save effect for existing configs (debounced)
+  useEffect(() => {
+    if (!isInitialized || !config) return;
+    if (
+      lastSavedAutosaveStateRef.current
+      && autoSaveStateFingerprint === serializeAutosaveState(lastSavedAutosaveStateRef.current)
+    ) return;
+
+    if (settingsAutoSaveTimerRef.current) {
+      clearTimeout(settingsAutoSaveTimerRef.current);
     }
 
-    autoSaveTimerRef.current = setTimeout(() => {
+    settingsAutoSaveTimerRef.current = setTimeout(() => {
       autoSave(false);
     }, 500);
 
     return () => {
-      if (autoSaveTimerRef.current) {
-        clearTimeout(autoSaveTimerRef.current);
+      if (settingsAutoSaveTimerRef.current) {
+        clearTimeout(settingsAutoSaveTimerRef.current);
       }
     };
-  }, [repoUrl, branch, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, backupSpools, backupArchives, enabled, autoSave, config?.has_token]);
+  }, [isInitialized, config, autoSaveStateFingerprint, autoSave]);
 
   // Auto-save token when it changes (with longer debounce)
   useEffect(() => {
-    if (!isInitializedRef.current || !config?.has_token || !accessToken) return;
+    if (!isInitialized || !config || !accessToken) return;
+    if (accessToken === lastTokenScheduledForSaveRef.current) return;
+    lastTokenScheduledForSaveRef.current = accessToken;
 
-    if (autoSaveTimerRef.current) {
-      clearTimeout(autoSaveTimerRef.current);
+    if (tokenAutoSaveTimerRef.current) {
+      clearTimeout(tokenAutoSaveTimerRef.current);
     }
 
-    autoSaveTimerRef.current = setTimeout(() => {
-      autoSave(true);
+    tokenAutoSaveTimerRef.current = setTimeout(() => {
+      autoSaveRef.current(true);
     }, 1000);
 
     return () => {
-      if (autoSaveTimerRef.current) {
-        clearTimeout(autoSaveTimerRef.current);
+      if (tokenAutoSaveTimerRef.current) {
+        clearTimeout(tokenAutoSaveTimerRef.current);
       }
     };
-  }, [accessToken, autoSave, config?.has_token]);
+  }, [isInitialized, accessToken, config]);
 
   // Mutations
   const saveConfigMutation = useMutation({
@@ -359,7 +464,7 @@ export function GitHubBackupSettings() {
       queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
       showToast(t('backup.githubBackupEnabled'));
       setAccessToken('');
-      isInitializedRef.current = true;
+      setIsInitialized(true);
     },
     onError: (error: Error) => {
       showToast(t('backup.failedToSave', { message: error.message }), 'error');
@@ -409,7 +514,7 @@ export function GitHubBackupSettings() {
           setTestLoading(false);
           return;
         }
-        result = await api.testGitHubConnection(repoUrl, accessToken);
+        result = await api.testGitHubConnection(repoUrl, accessToken, provider);
       } else if (config?.has_token) {
         // Use stored credentials
         result = await api.testGitHubStoredConnection();
@@ -441,6 +546,8 @@ export function GitHubBackupSettings() {
       repository_url: repoUrl,
       access_token: accessToken,
       branch,
+      provider,
+      allow_insecure_http: allowInsecureHttp,
       schedule_enabled: scheduleEnabled,
       schedule_type: scheduleType,
       backup_kprofiles: backupKProfiles,
@@ -462,7 +569,7 @@ export function GitHubBackupSettings() {
 
   return (
     <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
-      {/* Left Column - GitHub Backup */}
+      {/* Left Column - Git Backup */}
       <div className="space-y-6">
         <Card id="card-backup-github">
           <CardHeader>
@@ -487,6 +594,22 @@ export function GitHubBackupSettings() {
                   {t('backup.githubDescription')}
                 </p>
 
+            {/* Provider Selection */}
+            <div>
+              <label htmlFor="git-provider-select" className="block text-sm text-bambu-gray mb-1">{t('backup.provider')}</label>
+              <select
+                id="git-provider-select"
+                value={provider}
+                onChange={(e) => { setProvider(e.target.value as GitProviderType); setTestResult(null); }}
+                className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+              >
+                <option value="github">{t('backup.providerGitHub')}</option>
+                <option value="gitlab">{t('backup.providerGitLab')}</option>
+                <option value="gitea">{t('backup.providerGitea')}</option>
+                <option value="forgejo">{t('backup.providerForgejo')}</option>
+              </select>
+            </div>
+
                 {/* Repository URL */}
                 <div>
                   <label className="block text-sm text-bambu-gray mb-1">
@@ -496,9 +619,21 @@ export function GitHubBackupSettings() {
                     type="text"
                     value={repoUrl}
                     onChange={(e) => { setRepoUrl(e.target.value); setTestResult(null); }}
-                    placeholder="https://github.com/username/bambuddy-backup"
-                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                    placeholder={t(PROVIDER_REPO_URL_I18N_KEY[provider])}
+                    className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                   />
+                  <label className="flex items-start gap-2 mt-2 cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={allowInsecureHttp}
+                      onChange={(e) => { setAllowInsecureHttp(e.target.checked); setTestResult(null); }}
+                      className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+                    />
+                    <div>
+                      <span className="text-sm text-white">{t('backup.allowInsecureHttp')}</span>
+                      <p className="text-xs text-bambu-gray">{t('backup.allowInsecureHttpHint')}</p>
+                    </div>
+                  </label>
                 </div>
 
                 {/* Access Token */}
@@ -510,8 +645,8 @@ export function GitHubBackupSettings() {
                     type="password"
                     value={accessToken}
                     onChange={(e) => { setAccessToken(e.target.value); setTestResult(null); }}
-                    placeholder={config?.has_token ? t('backup.enterNewToken') : 'ghp_xxxxxxxxxxxx'}
-                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                    placeholder={config?.has_token ? t('backup.enterNewToken') : PROVIDER_TOKEN_PLACEHOLDER[provider]}
+                    className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                   />
                   <p className="text-xs text-bambu-gray mt-1">
                     {t('backup.tokenHint')}
@@ -527,7 +662,7 @@ export function GitHubBackupSettings() {
                   value={branch}
                   onChange={(e) => setBranch(e.target.value)}
                   placeholder="main"
-                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                  className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                 />
               </div>
               <div>
@@ -542,7 +677,7 @@ export function GitHubBackupSettings() {
                       setScheduleType(e.target.value as ScheduleType);
                     }
                   }}
-                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                  className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                 >
                   <option value="disabled">{t('backup.manualOnly')}</option>
                   <option value="hourly">{t('backup.hourly')}</option>
@@ -958,7 +1093,7 @@ export function GitHubBackupSettings() {
                     <label className="block text-sm text-bambu-gray mb-1">{t('backup.frequency')}</label>
                     <select
                       value={localBackupStatus?.schedule ?? 'daily'}
-                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                      className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                       onChange={async (e) => {
                         try {
                           await api.updateSettings({ local_backup_schedule: e.target.value });
@@ -980,7 +1115,7 @@ export function GitHubBackupSettings() {
                       <input
                         type="time"
                         value={localBackupStatus?.time ?? '03:00'}
-                        className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none [color-scheme:dark]"
+                        className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none [color-scheme:dark]"
                         onChange={async (e) => {
                           try {
                             await api.updateSettings({ local_backup_time: e.target.value });
@@ -1001,7 +1136,7 @@ export function GitHubBackupSettings() {
                       min={1}
                       max={100}
                       value={localBackupStatus?.retention ?? 5}
-                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                      className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                       onChange={async (e) => {
                         const val = Math.max(1, Math.min(100, parseInt(e.target.value) || 5));
                         try {
@@ -1024,7 +1159,7 @@ export function GitHubBackupSettings() {
                     type="text"
                     value={localBackupPath}
                     onChange={(e) => setLocalBackupPath(e.target.value)}
-                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                    className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                     onBlur={async () => {
                       try {
                         await api.updateSettings({ local_backup_path: localBackupPath });

+ 14 - 3
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',

+ 14 - 3
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',

+ 13 - 2
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',

+ 13 - 2
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',

+ 13 - 2
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: 'バックアップを復元',

+ 13 - 2
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',

+ 13 - 2
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: '恢复备份',

+ 13 - 2
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: '恢復備份',