mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
feat(backup): Extend Backup to other Git providers (#1160)
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from backend.app.core.compat import StrEnum
|
||||
|
||||
@@ -16,12 +16,22 @@ class ScheduleType(StrEnum):
|
||||
WEEKLY = "weekly"
|
||||
|
||||
|
||||
class ProviderType(StrEnum):
|
||||
"""Git hosting provider types."""
|
||||
|
||||
GITHUB = "github"
|
||||
GITLAB = "gitlab"
|
||||
GITEA = "gitea"
|
||||
FORGEJO = "forgejo"
|
||||
|
||||
|
||||
class GitHubBackupConfigCreate(BaseModel):
|
||||
"""Schema for creating/updating GitHub backup config."""
|
||||
|
||||
repository_url: str = Field(..., min_length=1, max_length=500, description="GitHub repository URL")
|
||||
repository_url: str = Field(..., min_length=1, max_length=500, description="Git repository URL")
|
||||
access_token: str = Field(..., min_length=1, description="Personal Access Token")
|
||||
branch: str = Field(default="main", max_length=100, description="Branch to push to")
|
||||
provider: ProviderType = Field(default=ProviderType.GITHUB, description="Git hosting provider")
|
||||
|
||||
schedule_enabled: bool = Field(default=False, description="Enable scheduled backups")
|
||||
schedule_type: ScheduleType = Field(default=ScheduleType.DAILY, description="Schedule frequency")
|
||||
@@ -32,21 +42,31 @@ class GitHubBackupConfigCreate(BaseModel):
|
||||
backup_spools: bool = Field(default=False, description="Backup spool inventory")
|
||||
backup_archives: bool = Field(default=False, description="Backup print archive history")
|
||||
|
||||
allow_insecure_http: bool = Field(default=False, description="Allow HTTP (non-TLS) repository URLs")
|
||||
enabled: bool = Field(default=True, description="Enable backup feature")
|
||||
|
||||
@field_validator("repository_url")
|
||||
@classmethod
|
||||
def validate_repo_url(cls, v: str) -> str:
|
||||
"""Validate GitHub repository URL format."""
|
||||
# Accept various GitHub URL formats
|
||||
patterns = [
|
||||
r"^https://github\.com/[\w.-]+/[\w.-]+(?:\.git)?$",
|
||||
r"^git@github\.com:[\w.-]+/[\w.-]+(?:\.git)?$",
|
||||
@model_validator(mode="after")
|
||||
def validate_repo_url(self) -> "GitHubBackupConfigCreate":
|
||||
url = self.repository_url.strip().rstrip("/")
|
||||
self.repository_url = url
|
||||
https_or_ssh = [
|
||||
r"^https://[\w.-]+(:\d+)?/[\w.-]+(\/[\w.-]+)+(?:\.git)?/?$",
|
||||
r"^git@[\w.-]+:[\w.-]+(\/[\w.-]+)+(?:\.git)?$",
|
||||
]
|
||||
v = v.strip().rstrip("/")
|
||||
if not any(re.match(p, v) for p in patterns):
|
||||
raise ValueError("Invalid GitHub repository URL. Expected format: https://github.com/owner/repo")
|
||||
return v
|
||||
http_pattern = r"^http://[\w.-]+(:\d+)?/[\w.-]+(\/[\w.-]+)+(?:\.git)?/?$"
|
||||
if any(re.match(p, url) for p in https_or_ssh):
|
||||
return self
|
||||
if re.match(http_pattern, url):
|
||||
if not self.allow_insecure_http:
|
||||
raise ValueError(
|
||||
"This URL uses HTTP instead of HTTPS. "
|
||||
"Enable 'Allow insecure HTTP' if your instance does not use TLS."
|
||||
)
|
||||
return self
|
||||
raise ValueError(
|
||||
"Invalid Git repository URL. Expected: https://host/owner/repo, "
|
||||
"http://host/owner/repo (with 'Allow insecure HTTP' enabled), or git@host:owner/repo"
|
||||
)
|
||||
|
||||
|
||||
class GitHubBackupConfigUpdate(BaseModel):
|
||||
@@ -55,6 +75,7 @@ class GitHubBackupConfigUpdate(BaseModel):
|
||||
repository_url: str | None = Field(default=None, max_length=500)
|
||||
access_token: str | None = Field(default=None)
|
||||
branch: str | None = Field(default=None, max_length=100)
|
||||
provider: ProviderType | None = None
|
||||
|
||||
schedule_enabled: bool | None = None
|
||||
schedule_type: ScheduleType | None = None
|
||||
@@ -65,21 +86,25 @@ class GitHubBackupConfigUpdate(BaseModel):
|
||||
backup_spools: bool | None = None
|
||||
backup_archives: bool | None = None
|
||||
|
||||
allow_insecure_http: bool | None = None
|
||||
enabled: bool | None = None
|
||||
|
||||
@field_validator("repository_url")
|
||||
@classmethod
|
||||
def validate_repo_url(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
patterns = [
|
||||
r"^https://github\.com/[\w.-]+/[\w.-]+(?:\.git)?$",
|
||||
r"^git@github\.com:[\w.-]+/[\w.-]+(?:\.git)?$",
|
||||
@model_validator(mode="after")
|
||||
def validate_repo_url(self) -> "GitHubBackupConfigUpdate":
|
||||
if self.repository_url is None:
|
||||
return self
|
||||
url = self.repository_url.strip().rstrip("/")
|
||||
self.repository_url = url
|
||||
valid_patterns = [
|
||||
r"^https?://[\w.-]+(:\d+)?/[\w.-]+(\/[\w.-]+)+(?:\.git)?/?$",
|
||||
r"^git@[\w.-]+:[\w.-]+(\/[\w.-]+)+(?:\.git)?$",
|
||||
]
|
||||
v = v.strip().rstrip("/")
|
||||
if not any(re.match(p, v) for p in patterns):
|
||||
raise ValueError("Invalid GitHub repository URL")
|
||||
return v
|
||||
if not any(re.match(p, url) for p in valid_patterns):
|
||||
raise ValueError(
|
||||
"Invalid repository URL. Expected: https://host/owner/repo, "
|
||||
"http://host/owner/repo, or git@host:owner/repo"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class GitHubBackupConfigResponse(BaseModel):
|
||||
@@ -89,6 +114,8 @@ class GitHubBackupConfigResponse(BaseModel):
|
||||
repository_url: str
|
||||
has_token: bool = Field(description="Whether an access token is configured")
|
||||
branch: str
|
||||
provider: str
|
||||
allow_insecure_http: bool
|
||||
|
||||
schedule_enabled: bool
|
||||
schedule_type: str
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
from backend.app.services.git_providers.base import GitProviderBackend
|
||||
from backend.app.services.git_providers.factory import get_provider_backend
|
||||
|
||||
__all__ = ["GitProviderBackend", "get_provider_backend"]
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Abstract base class for Git hosting provider backends."""
|
||||
|
||||
import hashlib
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class GitProviderBackend(ABC):
|
||||
"""Abstract base for Git hosting provider API backends."""
|
||||
|
||||
@staticmethod
|
||||
def _blob_sha(content_bytes: bytes) -> str:
|
||||
"""Compute the git blob SHA for content_bytes (sha1("blob {len}\\0" + data))."""
|
||||
return hashlib.sha1(
|
||||
f"blob {len(content_bytes)}\0".encode() + content_bytes, usedforsecurity=False
|
||||
).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _truncated_response_text(response: httpx.Response, max_length: int = 200) -> str:
|
||||
"""Return a bounded response body for errors surfaced to logs/UI."""
|
||||
text = response.text
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
return f"{text[: max_length - 3]}..."
|
||||
|
||||
def get_headers(self, token: str) -> dict:
|
||||
"""Return HTTP headers for authenticated API requests."""
|
||||
return {
|
||||
"Authorization": f"token {token}",
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
"User-Agent": "Bambuddy-Backup",
|
||||
}
|
||||
|
||||
@abstractmethod
|
||||
def parse_repo_url(self, url: str) -> tuple[str, str]:
|
||||
"""Return (owner, repo) extracted from the repository URL."""
|
||||
|
||||
@abstractmethod
|
||||
def get_api_base(self, repo_url: str) -> str:
|
||||
"""Return the API base URL for this provider instance."""
|
||||
|
||||
@abstractmethod
|
||||
async def test_connection(self, repo_url: str, token: str, client: httpx.AsyncClient) -> dict:
|
||||
"""Test API connectivity and push permissions. Returns success/message/repo_name/permissions."""
|
||||
|
||||
@abstractmethod
|
||||
async def push_files(
|
||||
self,
|
||||
repo_url: str,
|
||||
token: str,
|
||||
branch: str,
|
||||
files: dict,
|
||||
client: httpx.AsyncClient,
|
||||
) -> dict:
|
||||
"""Push files to the repository. Returns status/message/commit_sha/files_changed."""
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Factory for instantiating the correct Git provider backend."""
|
||||
|
||||
from backend.app.services.git_providers.base import GitProviderBackend
|
||||
from backend.app.services.git_providers.forgejo import ForgejoBackend
|
||||
from backend.app.services.git_providers.gitea import GiteaBackend
|
||||
from backend.app.services.git_providers.github import GitHubBackend
|
||||
from backend.app.services.git_providers.gitlab import GitLabBackend
|
||||
|
||||
_BACKENDS: dict[str, type[GitProviderBackend]] = {
|
||||
"github": GitHubBackend,
|
||||
"gitea": GiteaBackend,
|
||||
"forgejo": ForgejoBackend,
|
||||
"gitlab": GitLabBackend,
|
||||
}
|
||||
|
||||
|
||||
def get_provider_backend(provider: str) -> GitProviderBackend:
|
||||
"""Return an instantiated backend for the given provider key."""
|
||||
backend_cls = _BACKENDS.get(provider)
|
||||
if backend_cls is None:
|
||||
raise ValueError(f"Unknown Git provider: {provider!r}")
|
||||
return backend_cls()
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Forgejo backend — currently API-compatible with Gitea (/api/v1)."""
|
||||
|
||||
from backend.app.services.git_providers.gitea import GiteaBackend
|
||||
|
||||
|
||||
class ForgejoBackend(GiteaBackend):
|
||||
"""Backend for Forgejo instances.
|
||||
|
||||
Currently API-compatible with Gitea (/api/v1). Override methods here
|
||||
as the two projects' APIs diverge.
|
||||
"""
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Gitea backend — uses the Git Data API inherited from GitHubBackend."""
|
||||
|
||||
import re
|
||||
|
||||
from backend.app.services.git_providers.github import GitHubBackend
|
||||
|
||||
|
||||
class GiteaBackend(GitHubBackend):
|
||||
"""Backend for Gitea instances.
|
||||
|
||||
Gitea's Git Data API (/api/v1/repos/{owner}/{repo}/git/...) is compatible
|
||||
with GitHub's, so push_files, _create_branch_and_push, and _create_initial_commit
|
||||
are inherited unchanged. Only the API base URL and Accept header differ.
|
||||
"""
|
||||
|
||||
def parse_repo_url(self, url: str) -> tuple[str, str]:
|
||||
"""Return (owner, repo) — accepts both https:// and http:// for self-hosted instances."""
|
||||
if not url or len(url) > 500:
|
||||
raise ValueError("Invalid Git URL: URL too long or empty")
|
||||
match = re.match(
|
||||
r"https?://[\w.\-]+(:\d+)?/([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?/?$",
|
||||
url,
|
||||
)
|
||||
if match:
|
||||
return match.group(2), match.group(3).removesuffix(".git")
|
||||
match = re.match(
|
||||
r"git@[\w.\-]+:([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?$",
|
||||
url,
|
||||
)
|
||||
if match:
|
||||
return match.group(1), match.group(2).removesuffix(".git")
|
||||
raise ValueError(f"Cannot parse repository URL: {url}")
|
||||
|
||||
def get_api_base(self, repo_url: str) -> str:
|
||||
"""Derive API base from the repository URL's scheme and host."""
|
||||
match = re.match(r"(https?://[\w.\-]+(:\d+)?)/", repo_url)
|
||||
if match:
|
||||
return f"{match.group(1)}/api/v1"
|
||||
raise ValueError(f"Cannot derive API base from URL: {repo_url}")
|
||||
|
||||
def get_headers(self, token: str) -> dict:
|
||||
headers = super().get_headers(token)
|
||||
headers["Accept"] = "application/json"
|
||||
return headers
|
||||
@@ -0,0 +1,335 @@
|
||||
"""GitHub backend — implements GitProviderBackend using the GitHub Git Data API."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.app.services.git_providers.base import GitProviderBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GitHubBackend(GitProviderBackend):
|
||||
"""Backend for github.com using the GitHub Git Data API."""
|
||||
|
||||
def get_api_base(self, repo_url: str) -> str:
|
||||
m = re.match(r"https?://([\w.\-]+(:\d+)?)/", repo_url)
|
||||
if m:
|
||||
host = m.group(1)
|
||||
return "https://api.github.com" if host == "github.com" else f"https://{host}/api/v3"
|
||||
m = re.match(r"git@([\w.\-]+):", repo_url)
|
||||
if m:
|
||||
host = m.group(1)
|
||||
return "https://api.github.com" if host == "github.com" else f"https://{host}/api/v3"
|
||||
return "https://api.github.com"
|
||||
|
||||
def parse_repo_url(self, url: str) -> tuple[str, str]:
|
||||
"""Return (owner, repo) from a Git HTTPS or SSH URL."""
|
||||
if not url or len(url) > 500:
|
||||
raise ValueError("Invalid Git URL: URL too long or empty")
|
||||
|
||||
# HTTPS: https://<host>[:<port>]/<owner>/<repo>[.git][/]
|
||||
match = re.match(
|
||||
r"https://[\w.\-]+(:\d+)?/([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?/?$",
|
||||
url,
|
||||
)
|
||||
if match:
|
||||
return match.group(2), match.group(3).removesuffix(".git")
|
||||
|
||||
# SSH: git@<host>:<owner>/<repo>[.git]
|
||||
match = re.match(
|
||||
r"git@[\w.\-]+:([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?$",
|
||||
url,
|
||||
)
|
||||
if match:
|
||||
return match.group(1), match.group(2).removesuffix(".git")
|
||||
|
||||
raise ValueError(f"Cannot parse repository URL: {url}")
|
||||
|
||||
async def test_connection(self, repo_url: str, token: str, client: httpx.AsyncClient) -> dict:
|
||||
"""Test API access and push permission for the repository."""
|
||||
try:
|
||||
owner, repo = self.parse_repo_url(repo_url)
|
||||
api_base = self.get_api_base(repo_url)
|
||||
headers = self.get_headers(token)
|
||||
|
||||
response = await client.get(f"{api_base}/repos/{owner}/{repo}", headers=headers)
|
||||
|
||||
if response.status_code == 401:
|
||||
return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
|
||||
|
||||
if response.status_code == 404:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Repository not found. Check URL and token permissions.",
|
||||
"repo_name": None,
|
||||
"permissions": None,
|
||||
}
|
||||
|
||||
if response.status_code != 200:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"API error: {response.status_code}",
|
||||
"repo_name": None,
|
||||
"permissions": None,
|
||||
}
|
||||
|
||||
data = response.json()
|
||||
permissions = data.get("permissions", {})
|
||||
|
||||
if not permissions.get("push", False):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Token does not have push permission to this repository",
|
||||
"repo_name": data.get("full_name"),
|
||||
"permissions": permissions,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Connection successful",
|
||||
"repo_name": data.get("full_name"),
|
||||
"permissions": permissions,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Git connection test failed: %s", e)
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Connection failed: {type(e).__name__}",
|
||||
"repo_name": None,
|
||||
"permissions": None,
|
||||
}
|
||||
|
||||
async def push_files(
|
||||
self,
|
||||
repo_url: str,
|
||||
token: str,
|
||||
branch: str,
|
||||
files: dict,
|
||||
client: httpx.AsyncClient,
|
||||
) -> dict:
|
||||
"""Push files to the repository using the Git Data API."""
|
||||
try:
|
||||
owner, repo = self.parse_repo_url(repo_url)
|
||||
api_base = self.get_api_base(repo_url)
|
||||
headers = self.get_headers(token)
|
||||
|
||||
ref_response = await client.get(f"{api_base}/repos/{owner}/{repo}/git/refs/heads/{branch}", headers=headers)
|
||||
|
||||
if ref_response.status_code == 404:
|
||||
return await self._create_branch_and_push(
|
||||
client, headers, api_base, owner, repo, branch, files, repo_url, token
|
||||
)
|
||||
|
||||
if ref_response.status_code != 200:
|
||||
return {
|
||||
"status": "failed",
|
||||
"message": f"Failed to get branch ref: {ref_response.status_code}",
|
||||
"error": self._truncated_response_text(ref_response),
|
||||
}
|
||||
|
||||
current_commit_sha = ref_response.json()["object"]["sha"]
|
||||
|
||||
commit_response = await client.get(
|
||||
f"{api_base}/repos/{owner}/{repo}/git/commits/{current_commit_sha}", headers=headers
|
||||
)
|
||||
if commit_response.status_code != 200:
|
||||
return {"status": "failed", "message": "Failed to get current commit"}
|
||||
|
||||
current_tree_sha = commit_response.json()["tree"]["sha"]
|
||||
|
||||
tree_response = await client.get(
|
||||
f"{api_base}/repos/{owner}/{repo}/git/trees/{current_tree_sha}?recursive=1", headers=headers
|
||||
)
|
||||
existing_files: dict[str, str] = {}
|
||||
if tree_response.status_code == 200:
|
||||
for item in tree_response.json().get("tree", []):
|
||||
if item["type"] == "blob":
|
||||
existing_files[item["path"]] = item["sha"]
|
||||
|
||||
tree_items = []
|
||||
files_changed = 0
|
||||
|
||||
for path, content in files.items():
|
||||
content_str = json.dumps(content, indent=2, default=str)
|
||||
content_bytes = content_str.encode("utf-8")
|
||||
content_sha = self._blob_sha(content_bytes)
|
||||
|
||||
if path in existing_files and existing_files[path] == content_sha:
|
||||
continue
|
||||
|
||||
blob_response = await client.post(
|
||||
f"{api_base}/repos/{owner}/{repo}/git/blobs",
|
||||
headers=headers,
|
||||
json={"content": base64.b64encode(content_bytes).decode(), "encoding": "base64"},
|
||||
)
|
||||
if blob_response.status_code != 201:
|
||||
logger.error("Failed to create blob for %s: %s", path, self._truncated_response_text(blob_response))
|
||||
continue
|
||||
|
||||
tree_items.append({"path": path, "mode": "100644", "type": "blob", "sha": blob_response.json()["sha"]})
|
||||
files_changed += 1
|
||||
|
||||
if not tree_items:
|
||||
return {"status": "skipped", "message": "No changes to commit", "commit_sha": None, "files_changed": 0}
|
||||
|
||||
tree_response = await client.post(
|
||||
f"{api_base}/repos/{owner}/{repo}/git/trees",
|
||||
headers=headers,
|
||||
json={"base_tree": current_tree_sha, "tree": tree_items},
|
||||
)
|
||||
if tree_response.status_code != 201:
|
||||
return {
|
||||
"status": "failed",
|
||||
"message": f"Failed to create tree: {self._truncated_response_text(tree_response)}",
|
||||
}
|
||||
|
||||
new_tree_sha = tree_response.json()["sha"]
|
||||
commit_message = f"Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}"
|
||||
commit_response = await client.post(
|
||||
f"{api_base}/repos/{owner}/{repo}/git/commits",
|
||||
headers=headers,
|
||||
json={"message": commit_message, "tree": new_tree_sha, "parents": [current_commit_sha]},
|
||||
)
|
||||
if commit_response.status_code != 201:
|
||||
return {
|
||||
"status": "failed",
|
||||
"message": f"Failed to create commit: {self._truncated_response_text(commit_response)}",
|
||||
}
|
||||
|
||||
new_commit_sha = commit_response.json()["sha"]
|
||||
|
||||
ref_update = await client.patch(
|
||||
f"{api_base}/repos/{owner}/{repo}/git/refs/heads/{branch}",
|
||||
headers=headers,
|
||||
json={"sha": new_commit_sha},
|
||||
)
|
||||
if ref_update.status_code != 200:
|
||||
return {
|
||||
"status": "failed",
|
||||
"message": f"Failed to update branch: {self._truncated_response_text(ref_update)}",
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Backup successful - {files_changed} files updated",
|
||||
"commit_sha": new_commit_sha,
|
||||
"files_changed": files_changed,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Push to Git failed: %s", e)
|
||||
return {"status": "failed", "message": str(e), "error": str(e)}
|
||||
|
||||
async def _create_branch_and_push(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict,
|
||||
api_base: str,
|
||||
owner: str,
|
||||
repo: str,
|
||||
branch: str,
|
||||
files: dict,
|
||||
repo_url: str,
|
||||
token: str,
|
||||
) -> dict:
|
||||
"""Create branch (from default branch or as initial commit) then push."""
|
||||
try:
|
||||
repo_response = await client.get(f"{api_base}/repos/{owner}/{repo}", headers=headers)
|
||||
if repo_response.status_code != 200:
|
||||
return {"status": "failed", "message": "Failed to get repo info"}
|
||||
|
||||
default_branch = repo_response.json().get("default_branch", "main")
|
||||
|
||||
ref_response = await client.get(
|
||||
f"{api_base}/repos/{owner}/{repo}/git/refs/heads/{default_branch}", headers=headers
|
||||
)
|
||||
if ref_response.status_code != 200:
|
||||
return await self._create_initial_commit(client, headers, api_base, owner, repo, branch, files)
|
||||
|
||||
base_sha = ref_response.json()["object"]["sha"]
|
||||
|
||||
create_ref = await client.post(
|
||||
f"{api_base}/repos/{owner}/{repo}/git/refs",
|
||||
headers=headers,
|
||||
json={"ref": f"refs/heads/{branch}", "sha": base_sha},
|
||||
)
|
||||
if create_ref.status_code != 201:
|
||||
return {
|
||||
"status": "failed",
|
||||
"message": f"Failed to create branch: {self._truncated_response_text(create_ref)}",
|
||||
}
|
||||
|
||||
return await self.push_files(repo_url, token, branch, files, client)
|
||||
|
||||
except Exception as e:
|
||||
return {"status": "failed", "message": str(e)}
|
||||
|
||||
async def _create_initial_commit(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict,
|
||||
api_base: str,
|
||||
owner: str,
|
||||
repo: str,
|
||||
branch: str,
|
||||
files: dict,
|
||||
) -> dict:
|
||||
"""Create the first commit in an empty repository."""
|
||||
try:
|
||||
tree_items = []
|
||||
for path, content in files.items():
|
||||
content_str = json.dumps(content, indent=2, default=str)
|
||||
blob_response = await client.post(
|
||||
f"{api_base}/repos/{owner}/{repo}/git/blobs",
|
||||
headers=headers,
|
||||
json={"content": base64.b64encode(content_str.encode()).decode(), "encoding": "base64"},
|
||||
)
|
||||
if blob_response.status_code == 201:
|
||||
tree_items.append(
|
||||
{"path": path, "mode": "100644", "type": "blob", "sha": blob_response.json()["sha"]}
|
||||
)
|
||||
|
||||
tree_response = await client.post(
|
||||
f"{api_base}/repos/{owner}/{repo}/git/trees",
|
||||
headers=headers,
|
||||
json={"tree": tree_items},
|
||||
)
|
||||
if tree_response.status_code != 201:
|
||||
return {"status": "failed", "message": "Failed to create tree"}
|
||||
|
||||
tree_sha = tree_response.json()["sha"]
|
||||
commit_response = await client.post(
|
||||
f"{api_base}/repos/{owner}/{repo}/git/commits",
|
||||
headers=headers,
|
||||
json={
|
||||
"message": f"Initial Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}",
|
||||
"tree": tree_sha,
|
||||
},
|
||||
)
|
||||
if commit_response.status_code != 201:
|
||||
return {"status": "failed", "message": "Failed to create commit"}
|
||||
|
||||
commit_sha = commit_response.json()["sha"]
|
||||
ref_response = await client.post(
|
||||
f"{api_base}/repos/{owner}/{repo}/git/refs",
|
||||
headers=headers,
|
||||
json={"ref": f"refs/heads/{branch}", "sha": commit_sha},
|
||||
)
|
||||
if ref_response.status_code != 201:
|
||||
return {"status": "failed", "message": "Failed to create branch ref"}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Initial backup created - {len(files)} files",
|
||||
"commit_sha": commit_sha,
|
||||
"files_changed": len(files),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"status": "failed", "message": str(e)}
|
||||
@@ -0,0 +1,257 @@
|
||||
"""GitLab backend — implements GitProviderBackend using the GitLab REST API v4."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import urllib.parse
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.app.services.git_providers.base import GitProviderBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GitLabBackend(GitProviderBackend):
|
||||
"""Backend for gitlab.com and self-hosted GitLab instances."""
|
||||
|
||||
def get_api_base(self, repo_url: str) -> str:
|
||||
match = re.match(r"(https?://[\w.\-]+(:\d+)?)/", repo_url)
|
||||
if not match:
|
||||
raise ValueError(f"Cannot derive API base from URL: {repo_url}")
|
||||
return f"{match.group(1)}/api/v4"
|
||||
|
||||
def get_headers(self, token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
|
||||
def parse_repo_url(self, url: str) -> tuple[str, str]:
|
||||
"""Return (namespace, repo) from HTTPS or SSH URL.
|
||||
|
||||
namespace may include subgroups, e.g. 'group/subgroup' for
|
||||
gitlab.com/group/subgroup/project. Callers join them with '/' and
|
||||
URL-encode the result for /api/v4/projects/{encoded_path}.
|
||||
"""
|
||||
if not url or len(url) > 500:
|
||||
raise ValueError("Invalid Git URL: URL too long or empty")
|
||||
match = re.match(r"https?://[\w.\-]+(:\d+)?/(.+?)(?:\.git)?/?$", url)
|
||||
if match:
|
||||
full_path = match.group(2)
|
||||
if "/" not in full_path:
|
||||
raise ValueError(f"Cannot parse repository URL: {url}")
|
||||
namespace, _, repo = full_path.rpartition("/")
|
||||
return namespace, repo
|
||||
match = re.match(r"git@[\w.\-]+:(.+?)(?:\.git)?$", url)
|
||||
if match:
|
||||
full_path = match.group(1)
|
||||
if "/" not in full_path:
|
||||
raise ValueError(f"Cannot parse repository URL: {url}")
|
||||
namespace, _, repo = full_path.rpartition("/")
|
||||
return namespace, repo
|
||||
raise ValueError(f"Cannot parse repository URL: {url}")
|
||||
|
||||
async def test_connection(self, repo_url: str, token: str, client: httpx.AsyncClient) -> dict:
|
||||
try:
|
||||
owner, repo = self.parse_repo_url(repo_url)
|
||||
api_base = self.get_api_base(repo_url)
|
||||
headers = self.get_headers(token)
|
||||
encoded_path = urllib.parse.quote(f"{owner}/{repo}", safe="")
|
||||
|
||||
response = await client.get(f"{api_base}/projects/{encoded_path}", headers=headers)
|
||||
|
||||
if response.status_code == 401:
|
||||
return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
|
||||
if response.status_code == 404:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Repository not found. Check URL and token permissions.",
|
||||
"repo_name": None,
|
||||
"permissions": None,
|
||||
}
|
||||
if response.status_code != 200:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"API error: {response.status_code}",
|
||||
"repo_name": None,
|
||||
"permissions": None,
|
||||
}
|
||||
|
||||
data = response.json()
|
||||
perms = data.get("permissions") or {}
|
||||
project_level = (perms.get("project_access") or {}).get("access_level", 0)
|
||||
group_level = (perms.get("group_access") or {}).get("access_level", 0)
|
||||
effective = max(project_level, group_level)
|
||||
|
||||
if effective < 30: # Developer = 30, Maintainer = 40, Owner = 50
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Token requires Developer access or higher to push",
|
||||
"repo_name": data.get("name_with_namespace"),
|
||||
"permissions": perms,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Connection successful",
|
||||
"repo_name": data.get("name_with_namespace"),
|
||||
"permissions": perms,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("GitLab connection test failed: %s", e)
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Connection failed: {type(e).__name__}",
|
||||
"repo_name": None,
|
||||
"permissions": None,
|
||||
}
|
||||
|
||||
async def push_files(
|
||||
self,
|
||||
repo_url: str,
|
||||
token: str,
|
||||
branch: str,
|
||||
files: dict,
|
||||
client: httpx.AsyncClient,
|
||||
) -> dict:
|
||||
try:
|
||||
owner, repo = self.parse_repo_url(repo_url)
|
||||
api_base = self.get_api_base(repo_url)
|
||||
headers = self.get_headers(token)
|
||||
encoded_path = urllib.parse.quote(f"{owner}/{repo}", safe="")
|
||||
|
||||
encoded_branch = urllib.parse.quote(branch, safe="")
|
||||
branch_response = await client.get(
|
||||
f"{api_base}/projects/{encoded_path}/repository/branches/{encoded_branch}",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
if branch_response.status_code == 404:
|
||||
proj_response = await client.get(f"{api_base}/projects/{encoded_path}", headers=headers)
|
||||
if proj_response.status_code != 200:
|
||||
return {"status": "failed", "message": "Failed to get project info"}
|
||||
|
||||
default_branch = proj_response.json().get("default_branch", "main")
|
||||
default_encoded = urllib.parse.quote(default_branch, safe="")
|
||||
default_response = await client.get(
|
||||
f"{api_base}/projects/{encoded_path}/repository/branches/{default_encoded}",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
if default_response.status_code != 200:
|
||||
return await self._create_initial_commit(client, headers, api_base, encoded_path, branch, files)
|
||||
|
||||
create_response = await client.post(
|
||||
f"{api_base}/projects/{encoded_path}/repository/branches",
|
||||
headers=headers,
|
||||
json={"branch": branch, "ref": default_branch},
|
||||
)
|
||||
if create_response.status_code not in (200, 201):
|
||||
return {"status": "failed", "message": f"Failed to create branch: {create_response.status_code}"}
|
||||
elif branch_response.status_code != 200:
|
||||
return {"status": "failed", "message": f"Failed to check branch: {branch_response.status_code}"}
|
||||
|
||||
existing_blobs: dict[str, str] = {}
|
||||
page = 1
|
||||
while True:
|
||||
tree_response = await client.get(
|
||||
f"{api_base}/projects/{encoded_path}/repository/tree",
|
||||
headers=headers,
|
||||
params={"recursive": "true", "ref": branch, "per_page": 100, "page": page},
|
||||
)
|
||||
if tree_response.status_code != 200:
|
||||
break
|
||||
items = tree_response.json()
|
||||
if not items:
|
||||
break
|
||||
for item in items:
|
||||
if item.get("type") == "blob":
|
||||
existing_blobs[item["path"]] = item["id"]
|
||||
page += 1
|
||||
|
||||
actions = []
|
||||
for path, content in files.items():
|
||||
content_str = json.dumps(content, indent=2, default=str)
|
||||
content_bytes = content_str.encode("utf-8")
|
||||
content_sha = self._blob_sha(content_bytes)
|
||||
|
||||
if path in existing_blobs and existing_blobs[path] == content_sha:
|
||||
continue
|
||||
|
||||
actions.append(
|
||||
{
|
||||
"action": "update" if path in existing_blobs else "create",
|
||||
"file_path": path,
|
||||
"content": base64.b64encode(content_bytes).decode(),
|
||||
"encoding": "base64",
|
||||
}
|
||||
)
|
||||
|
||||
if not actions:
|
||||
return {"status": "skipped", "message": "No changes to commit", "commit_sha": None, "files_changed": 0}
|
||||
|
||||
commit_message = f"Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}"
|
||||
commit_response = await client.post(
|
||||
f"{api_base}/projects/{encoded_path}/repository/commits",
|
||||
headers=headers,
|
||||
json={"branch": branch, "commit_message": commit_message, "actions": actions},
|
||||
)
|
||||
if commit_response.status_code not in (200, 201):
|
||||
return {
|
||||
"status": "failed",
|
||||
"message": f"Failed to create commit: {self._truncated_response_text(commit_response)}",
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Backup successful - {len(actions)} files updated",
|
||||
"commit_sha": commit_response.json().get("id"),
|
||||
"files_changed": len(actions),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Push to GitLab failed: %s", e)
|
||||
return {"status": "failed", "message": str(e), "error": str(e)}
|
||||
|
||||
async def _create_initial_commit(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict,
|
||||
api_base: str,
|
||||
encoded_path: str,
|
||||
branch: str,
|
||||
files: dict,
|
||||
) -> dict:
|
||||
"""Create the first commit in an empty repository."""
|
||||
try:
|
||||
actions = []
|
||||
for path, content in files.items():
|
||||
content_str = json.dumps(content, indent=2, default=str)
|
||||
actions.append(
|
||||
{
|
||||
"action": "create",
|
||||
"file_path": path,
|
||||
"content": base64.b64encode(content_str.encode()).decode(),
|
||||
"encoding": "base64",
|
||||
}
|
||||
)
|
||||
|
||||
commit_message = f"Initial Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}"
|
||||
commit_response = await client.post(
|
||||
f"{api_base}/projects/{encoded_path}/repository/commits",
|
||||
headers=headers,
|
||||
json={"branch": branch, "commit_message": commit_message, "actions": actions, "start_branch": branch},
|
||||
)
|
||||
if commit_response.status_code not in (200, 201):
|
||||
return {
|
||||
"status": "failed",
|
||||
"message": f"Failed to create initial commit: {self._truncated_response_text(commit_response)}",
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Initial backup created - {len(files)} files",
|
||||
"commit_sha": commit_response.json().get("id"),
|
||||
"files_changed": len(files),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "failed", "message": str(e)}
|
||||
@@ -4,11 +4,7 @@ Handles scheduled and on-demand backups of K-profiles and cloud profiles to GitH
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
@@ -22,6 +18,7 @@ from backend.app.models.printer import Printer
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.spool import Spool
|
||||
from backend.app.models.spool_usage_history import SpoolUsageHistory
|
||||
from backend.app.services.git_providers.factory import get_provider_backend
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -33,6 +30,13 @@ SCHEDULE_INTERVALS = {
|
||||
"weekly": 604800,
|
||||
}
|
||||
|
||||
_PROVIDER_DISPLAY_NAMES = {
|
||||
"github": "GitHub",
|
||||
"gitlab": "GitLab",
|
||||
"gitea": "Gitea",
|
||||
"forgejo": "Forgejo",
|
||||
}
|
||||
|
||||
|
||||
class GitHubBackupService:
|
||||
"""Service for backing up profiles to GitHub."""
|
||||
@@ -97,104 +101,17 @@ class GitHubBackupService:
|
||||
logger.info("Running scheduled backup for config %s", config.id)
|
||||
await self.run_backup(config.id, trigger="scheduled")
|
||||
|
||||
def _calculate_next_run(self, schedule_type: str, from_time: datetime | None = None) -> datetime:
|
||||
def calculate_next_run(self, schedule_type: str, from_time: datetime | None = None) -> datetime:
|
||||
"""Calculate the next scheduled run time."""
|
||||
now = from_time or datetime.now(timezone.utc)
|
||||
interval = SCHEDULE_INTERVALS.get(schedule_type, SCHEDULE_INTERVALS["daily"])
|
||||
return now + timedelta(seconds=interval)
|
||||
|
||||
async def test_connection(self, repo_url: str, token: str) -> dict:
|
||||
"""Test GitHub connection and permissions.
|
||||
|
||||
Args:
|
||||
repo_url: GitHub repository URL
|
||||
token: Personal Access Token
|
||||
|
||||
Returns:
|
||||
dict with success, message, repo_name, permissions
|
||||
"""
|
||||
try:
|
||||
owner, repo = self._parse_repo_url(repo_url)
|
||||
client = await self._get_client()
|
||||
|
||||
# Test API access
|
||||
response = await client.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}",
|
||||
headers={
|
||||
"Authorization": f"token {token}",
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
"User-Agent": "Bambuddy-Backup",
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
|
||||
|
||||
if response.status_code == 404:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Repository not found. Check URL and token permissions.",
|
||||
"repo_name": None,
|
||||
"permissions": None,
|
||||
}
|
||||
|
||||
if response.status_code != 200:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"GitHub API error: {response.status_code}",
|
||||
"repo_name": None,
|
||||
"permissions": None,
|
||||
}
|
||||
|
||||
data = response.json()
|
||||
permissions = data.get("permissions", {})
|
||||
|
||||
# Check for push permission
|
||||
if not permissions.get("push", False):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Token does not have push permission to this repository",
|
||||
"repo_name": data.get("full_name"),
|
||||
"permissions": permissions,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Connection successful",
|
||||
"repo_name": data.get("full_name"),
|
||||
"permissions": permissions,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("GitHub connection test failed: %s", e)
|
||||
# Sanitize error - don't expose internal details
|
||||
error_type = type(e).__name__
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Connection failed: {error_type}",
|
||||
"repo_name": None,
|
||||
"permissions": None,
|
||||
}
|
||||
|
||||
def _parse_repo_url(self, url: str) -> tuple[str, str]:
|
||||
"""Parse owner and repo from GitHub URL."""
|
||||
# Limit URL length to prevent ReDoS attacks
|
||||
if not url or len(url) > 500:
|
||||
raise ValueError("Invalid GitHub URL: URL too long or empty")
|
||||
|
||||
# Handle HTTPS URLs - use atomic groups via limited character classes
|
||||
# GitHub usernames: 1-39 chars, alphanumeric and hyphens
|
||||
# Repo names: 1-100 chars, alphanumeric, hyphens, underscores, dots
|
||||
match = re.match(r"https://github\.com/([\w-]{1,39})/([\w.\-]{1,100})(?:\.git)?/?$", url)
|
||||
if match:
|
||||
return match.group(1), match.group(2)
|
||||
|
||||
# Handle SSH URLs
|
||||
match = re.match(r"git@github\.com:([\w-]{1,39})/([\w.\-]{1,100})(?:\.git)?$", url)
|
||||
if match:
|
||||
return match.group(1), match.group(2)
|
||||
|
||||
raise ValueError(f"Invalid GitHub URL: {url}")
|
||||
async def test_connection(self, repo_url: str, token: str, provider: str = "github") -> dict:
|
||||
"""Test connection and permissions for the given provider."""
|
||||
backend = get_provider_backend(provider)
|
||||
client = await self._get_client()
|
||||
return await backend.test_connection(repo_url, token, client)
|
||||
|
||||
async def run_backup(self, config_id: int, trigger: str = "manual") -> dict:
|
||||
"""Run a backup operation.
|
||||
@@ -245,7 +162,7 @@ class GitHubBackupService:
|
||||
config.last_backup_status = "skipped"
|
||||
config.last_backup_message = "No data to backup"
|
||||
if config.schedule_enabled:
|
||||
config.next_scheduled_run = self._calculate_next_run(config.schedule_type)
|
||||
config.next_scheduled_run = self.calculate_next_run(config.schedule_type)
|
||||
await db.commit()
|
||||
return {
|
||||
"success": True,
|
||||
@@ -255,9 +172,9 @@ class GitHubBackupService:
|
||||
"files_changed": 0,
|
||||
}
|
||||
|
||||
# Push to GitHub
|
||||
self._backup_progress = "Pushing to GitHub..."
|
||||
push_result = await self._push_to_github(config, backup_data)
|
||||
provider_name = _PROVIDER_DISPLAY_NAMES.get(config.provider, config.provider)
|
||||
self._backup_progress = f"Pushing to {provider_name}..."
|
||||
push_result = await self._push_to_provider(config, backup_data)
|
||||
|
||||
# Update log and config
|
||||
log.status = push_result["status"]
|
||||
@@ -272,7 +189,7 @@ class GitHubBackupService:
|
||||
config.last_backup_commit_sha = push_result.get("commit_sha")
|
||||
|
||||
if config.schedule_enabled:
|
||||
config.next_scheduled_run = self._calculate_next_run(config.schedule_type)
|
||||
config.next_scheduled_run = self.calculate_next_run(config.schedule_type)
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -295,7 +212,7 @@ class GitHubBackupService:
|
||||
config.last_backup_message = str(e)
|
||||
|
||||
if config.schedule_enabled:
|
||||
config.next_scheduled_run = self._calculate_next_run(config.schedule_type)
|
||||
config.next_scheduled_run = self.calculate_next_run(config.schedule_type)
|
||||
|
||||
await db.commit()
|
||||
return {
|
||||
@@ -609,252 +526,17 @@ class GitHubBackupService:
|
||||
|
||||
logger.info("Collected %d print archives", len(archive_list))
|
||||
|
||||
async def _push_to_github(self, config: GitHubBackupConfig, files: dict) -> dict:
|
||||
"""Push files to GitHub using the GitHub API.
|
||||
|
||||
Uses the Git Data API to create blobs, tree, and commit.
|
||||
|
||||
Returns:
|
||||
dict with status, message, commit_sha, files_changed
|
||||
"""
|
||||
try:
|
||||
owner, repo = self._parse_repo_url(config.repository_url)
|
||||
branch = config.branch
|
||||
client = await self._get_client()
|
||||
headers = {
|
||||
"Authorization": f"token {config.access_token}",
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
"User-Agent": "Bambuddy-Backup",
|
||||
}
|
||||
|
||||
# Get current branch reference
|
||||
ref_response = await client.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/{branch}", headers=headers
|
||||
)
|
||||
|
||||
if ref_response.status_code == 404:
|
||||
# Branch doesn't exist, need to create it from default branch
|
||||
return await self._create_branch_and_push(client, headers, owner, repo, branch, files)
|
||||
|
||||
if ref_response.status_code != 200:
|
||||
return {
|
||||
"status": "failed",
|
||||
"message": f"Failed to get branch ref: {ref_response.status_code}",
|
||||
"error": ref_response.text,
|
||||
}
|
||||
|
||||
ref_data = ref_response.json()
|
||||
current_commit_sha = ref_data["object"]["sha"]
|
||||
|
||||
# Get the current tree
|
||||
commit_response = await client.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/commits/{current_commit_sha}", headers=headers
|
||||
)
|
||||
if commit_response.status_code != 200:
|
||||
return {"status": "failed", "message": "Failed to get current commit"}
|
||||
|
||||
current_tree_sha = commit_response.json()["tree"]["sha"]
|
||||
|
||||
# Get existing files to check for changes
|
||||
tree_response = await client.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/trees/{current_tree_sha}?recursive=1", headers=headers
|
||||
)
|
||||
existing_files = {}
|
||||
if tree_response.status_code == 200:
|
||||
for item in tree_response.json().get("tree", []):
|
||||
if item["type"] == "blob":
|
||||
existing_files[item["path"]] = item["sha"]
|
||||
|
||||
# Create blobs for changed files
|
||||
tree_items = []
|
||||
files_changed = 0
|
||||
|
||||
for path, content in files.items():
|
||||
content_str = json.dumps(content, indent=2, default=str)
|
||||
content_bytes = content_str.encode("utf-8")
|
||||
content_sha = hashlib.sha1(
|
||||
f"blob {len(content_bytes)}\0".encode() + content_bytes, usedforsecurity=False
|
||||
).hexdigest()
|
||||
|
||||
# Skip if file hasn't changed
|
||||
if path in existing_files and existing_files[path] == content_sha:
|
||||
continue
|
||||
|
||||
# Create blob
|
||||
blob_response = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/blobs",
|
||||
headers=headers,
|
||||
json={"content": base64.b64encode(content_bytes).decode(), "encoding": "base64"},
|
||||
)
|
||||
|
||||
if blob_response.status_code != 201:
|
||||
logger.error("Failed to create blob for %s: %s", path, blob_response.text)
|
||||
continue
|
||||
|
||||
blob_sha = blob_response.json()["sha"]
|
||||
tree_items.append({"path": path, "mode": "100644", "type": "blob", "sha": blob_sha})
|
||||
files_changed += 1
|
||||
|
||||
if not tree_items:
|
||||
return {"status": "skipped", "message": "No changes to commit", "commit_sha": None, "files_changed": 0}
|
||||
|
||||
# Create new tree
|
||||
tree_response = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/trees",
|
||||
headers=headers,
|
||||
json={"base_tree": current_tree_sha, "tree": tree_items},
|
||||
)
|
||||
|
||||
if tree_response.status_code != 201:
|
||||
return {"status": "failed", "message": f"Failed to create tree: {tree_response.text}"}
|
||||
|
||||
new_tree_sha = tree_response.json()["sha"]
|
||||
|
||||
# Create commit
|
||||
commit_message = f"Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}"
|
||||
commit_response = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/commits",
|
||||
headers=headers,
|
||||
json={"message": commit_message, "tree": new_tree_sha, "parents": [current_commit_sha]},
|
||||
)
|
||||
|
||||
if commit_response.status_code != 201:
|
||||
return {"status": "failed", "message": f"Failed to create commit: {commit_response.text}"}
|
||||
|
||||
new_commit_sha = commit_response.json()["sha"]
|
||||
|
||||
# Update branch reference
|
||||
ref_update = await client.patch(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/{branch}",
|
||||
headers=headers,
|
||||
json={"sha": new_commit_sha},
|
||||
)
|
||||
|
||||
if ref_update.status_code != 200:
|
||||
return {"status": "failed", "message": f"Failed to update branch: {ref_update.text}"}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Backup successful - {files_changed} files updated",
|
||||
"commit_sha": new_commit_sha,
|
||||
"files_changed": files_changed,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Push to GitHub failed: %s", e)
|
||||
return {"status": "failed", "message": str(e), "error": str(e)}
|
||||
|
||||
async def _create_branch_and_push(
|
||||
self, client: httpx.AsyncClient, headers: dict, owner: str, repo: str, branch: str, files: dict
|
||||
) -> dict:
|
||||
"""Create a new branch and push files when branch doesn't exist."""
|
||||
try:
|
||||
# Get default branch
|
||||
repo_response = await client.get(f"https://api.github.com/repos/{owner}/{repo}", headers=headers)
|
||||
if repo_response.status_code != 200:
|
||||
return {"status": "failed", "message": "Failed to get repo info"}
|
||||
|
||||
default_branch = repo_response.json().get("default_branch", "main")
|
||||
|
||||
# Get default branch ref
|
||||
ref_response = await client.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/{default_branch}", headers=headers
|
||||
)
|
||||
if ref_response.status_code != 200:
|
||||
# Empty repo - create initial commit
|
||||
return await self._create_initial_commit(client, headers, owner, repo, branch, files)
|
||||
|
||||
base_sha = ref_response.json()["object"]["sha"]
|
||||
|
||||
# Create new branch
|
||||
create_ref = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/refs",
|
||||
headers=headers,
|
||||
json={"ref": f"refs/heads/{branch}", "sha": base_sha},
|
||||
)
|
||||
|
||||
if create_ref.status_code != 201:
|
||||
return {"status": "failed", "message": f"Failed to create branch: {create_ref.text}"}
|
||||
|
||||
# Now push to the new branch (recursive call will find the branch)
|
||||
return await self._push_to_github(
|
||||
type(
|
||||
"Config",
|
||||
(),
|
||||
{
|
||||
"repository_url": f"https://github.com/{owner}/{repo}",
|
||||
"access_token": headers["Authorization"].replace("token ", ""),
|
||||
"branch": branch,
|
||||
},
|
||||
)(),
|
||||
files,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return {"status": "failed", "message": str(e)}
|
||||
|
||||
async def _create_initial_commit(
|
||||
self, client: httpx.AsyncClient, headers: dict, owner: str, repo: str, branch: str, files: dict
|
||||
) -> dict:
|
||||
"""Create initial commit in an empty repository."""
|
||||
try:
|
||||
# Create blobs
|
||||
tree_items = []
|
||||
for path, content in files.items():
|
||||
content_str = json.dumps(content, indent=2, default=str)
|
||||
blob_response = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/blobs",
|
||||
headers=headers,
|
||||
json={"content": base64.b64encode(content_str.encode()).decode(), "encoding": "base64"},
|
||||
)
|
||||
if blob_response.status_code == 201:
|
||||
tree_items.append(
|
||||
{"path": path, "mode": "100644", "type": "blob", "sha": blob_response.json()["sha"]}
|
||||
)
|
||||
|
||||
# Create tree
|
||||
tree_response = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/trees",
|
||||
headers=headers,
|
||||
json={"tree": tree_items},
|
||||
)
|
||||
if tree_response.status_code != 201:
|
||||
return {"status": "failed", "message": "Failed to create tree"}
|
||||
|
||||
tree_sha = tree_response.json()["sha"]
|
||||
|
||||
# Create commit (no parents for initial)
|
||||
commit_response = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/commits",
|
||||
headers=headers,
|
||||
json={
|
||||
"message": f"Initial Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}",
|
||||
"tree": tree_sha,
|
||||
},
|
||||
)
|
||||
if commit_response.status_code != 201:
|
||||
return {"status": "failed", "message": "Failed to create commit"}
|
||||
|
||||
commit_sha = commit_response.json()["sha"]
|
||||
|
||||
# Create branch ref
|
||||
ref_response = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/refs",
|
||||
headers=headers,
|
||||
json={"ref": f"refs/heads/{branch}", "sha": commit_sha},
|
||||
)
|
||||
if ref_response.status_code != 201:
|
||||
return {"status": "failed", "message": "Failed to create branch ref"}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Initial backup created - {len(files)} files",
|
||||
"commit_sha": commit_sha,
|
||||
"files_changed": len(files),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"status": "failed", "message": str(e)}
|
||||
async def _push_to_provider(self, config: GitHubBackupConfig, files: dict) -> dict:
|
||||
"""Push files to the configured Git provider."""
|
||||
backend = get_provider_backend(config.provider)
|
||||
client = await self._get_client()
|
||||
return await backend.push_files(
|
||||
repo_url=config.repository_url,
|
||||
token=config.access_token,
|
||||
branch=config.branch,
|
||||
files=files,
|
||||
client=client,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
|
||||
@@ -162,6 +162,41 @@ class TestGitHubBackupConfigAPI:
|
||||
assert result["backup_kprofiles"] is True
|
||||
assert result["backup_settings"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_config_rejects_disabling_insecure_http_for_stored_http_url(
|
||||
self, async_client: AsyncClient
|
||||
):
|
||||
"""Verify PATCH rejects leaving a stored HTTP URL without explicit insecure-HTTP allowance."""
|
||||
create_data = {
|
||||
"repository_url": "http://git.example.com/test/httprepo",
|
||||
"access_token": "gitea_token",
|
||||
"branch": "main",
|
||||
"provider": "gitea",
|
||||
"allow_insecure_http": True,
|
||||
"schedule_enabled": False,
|
||||
"schedule_type": "daily",
|
||||
"backup_kprofiles": True,
|
||||
"backup_cloud_profiles": True,
|
||||
"backup_settings": False,
|
||||
"backup_spools": False,
|
||||
"backup_archives": False,
|
||||
"enabled": True,
|
||||
}
|
||||
create_response = await async_client.post("/api/v1/github-backup/config", json=create_data)
|
||||
assert create_response.status_code == 200
|
||||
|
||||
response = await async_client.patch("/api/v1/github-backup/config", json={"allow_insecure_http": False})
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "Allow insecure HTTP" in response.json()["detail"]
|
||||
|
||||
stored_response = await async_client.get("/api/v1/github-backup/config")
|
||||
assert stored_response.status_code == 200
|
||||
stored = stored_response.json()
|
||||
assert stored["repository_url"] == "http://git.example.com/test/httprepo"
|
||||
assert stored["allow_insecure_http"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_delete_config(self, async_client: AsyncClient):
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
"""Unit tests for the git_providers abstraction package."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services.git_providers.factory import get_provider_backend
|
||||
from backend.app.services.git_providers.forgejo import ForgejoBackend
|
||||
from backend.app.services.git_providers.gitea import GiteaBackend
|
||||
from backend.app.services.git_providers.github import GitHubBackend
|
||||
from backend.app.services.git_providers.gitlab import GitLabBackend
|
||||
|
||||
|
||||
class TestFactory:
|
||||
def test_known_providers_return_correct_class(self):
|
||||
assert isinstance(get_provider_backend("github"), GitHubBackend)
|
||||
assert isinstance(get_provider_backend("gitea"), GiteaBackend)
|
||||
assert isinstance(get_provider_backend("forgejo"), ForgejoBackend)
|
||||
assert isinstance(get_provider_backend("gitlab"), GitLabBackend)
|
||||
|
||||
def test_unknown_provider_raises_value_error(self):
|
||||
with pytest.raises(ValueError, match="Unknown Git provider"):
|
||||
get_provider_backend("bitbucket")
|
||||
|
||||
|
||||
class TestGitHubBackendParseUrl:
|
||||
def setup_method(self):
|
||||
self.backend = GitHubBackend()
|
||||
|
||||
def test_https_url(self):
|
||||
owner, repo = self.backend.parse_repo_url("https://github.com/owner/repo")
|
||||
assert owner == "owner"
|
||||
assert repo == "repo"
|
||||
|
||||
def test_https_url_with_git_suffix(self):
|
||||
owner, repo = self.backend.parse_repo_url("https://github.com/owner/repo.git")
|
||||
assert owner == "owner"
|
||||
assert repo == "repo"
|
||||
|
||||
def test_ssh_url(self):
|
||||
owner, repo = self.backend.parse_repo_url("git@github.com:owner/repo")
|
||||
assert owner == "owner"
|
||||
assert repo == "repo"
|
||||
|
||||
def test_ssh_url_with_git_suffix(self):
|
||||
owner, repo = self.backend.parse_repo_url("git@github.com:owner/repo.git")
|
||||
assert owner == "owner"
|
||||
assert repo == "repo"
|
||||
|
||||
def test_invalid_url_raises_value_error(self):
|
||||
with pytest.raises(ValueError, match="Cannot parse repository URL"):
|
||||
self.backend.parse_repo_url("https://example.com/not-a-repo")
|
||||
|
||||
def test_empty_url_raises_value_error(self):
|
||||
with pytest.raises(ValueError):
|
||||
self.backend.parse_repo_url("")
|
||||
|
||||
|
||||
class TestGitHubBackendApiBase:
|
||||
def setup_method(self):
|
||||
self.backend = GitHubBackend()
|
||||
|
||||
def test_github_com_returns_api_github_com(self):
|
||||
assert self.backend.get_api_base("https://github.com/owner/repo") == "https://api.github.com"
|
||||
|
||||
def test_ghe_host_returns_v3_endpoint(self):
|
||||
assert self.backend.get_api_base("https://github.example.com/owner/repo") == "https://github.example.com/api/v3"
|
||||
|
||||
def test_ghe_host_with_port(self):
|
||||
assert self.backend.get_api_base("https://github.example.com:8443/owner/repo") == "https://github.example.com:8443/api/v3"
|
||||
|
||||
def test_ssh_github_com_returns_api_github_com(self):
|
||||
assert self.backend.get_api_base("git@github.com:owner/repo.git") == "https://api.github.com"
|
||||
|
||||
def test_ssh_ghe_host_returns_v3_endpoint(self):
|
||||
assert self.backend.get_api_base("git@github.example.com:owner/repo.git") == "https://github.example.com/api/v3"
|
||||
|
||||
|
||||
class TestGiteaBackendApiBase:
|
||||
def setup_method(self):
|
||||
self.backend = GiteaBackend()
|
||||
|
||||
def test_derives_api_base_from_repo_url(self):
|
||||
result = self.backend.get_api_base("https://git.example.com/owner/repo")
|
||||
assert result == "https://git.example.com/api/v1"
|
||||
|
||||
def test_derives_api_base_with_port(self):
|
||||
result = self.backend.get_api_base("https://git.example.com:3000/owner/repo")
|
||||
assert result == "https://git.example.com:3000/api/v1"
|
||||
|
||||
def test_invalid_url_raises_value_error(self):
|
||||
with pytest.raises(ValueError, match="Cannot derive API base"):
|
||||
self.backend.get_api_base("not-a-url")
|
||||
|
||||
def test_parse_url_uses_instance_host(self):
|
||||
owner, repo = self.backend.parse_repo_url("https://git.example.com/owner/repo")
|
||||
assert owner == "owner"
|
||||
assert repo == "repo"
|
||||
|
||||
|
||||
class TestGiteaBackendPushFiles:
|
||||
def setup_method(self):
|
||||
self.backend = GiteaBackend()
|
||||
self.repo_url = "https://git.example.com/owner/repo"
|
||||
self.token = "gitea-token"
|
||||
self.branch = "bambuddy-backup"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_n_files_produce_single_commit(self):
|
||||
"""All changed files are bundled into one commit via the Git Data API."""
|
||||
files = {"a.json": {"k": "v1"}, "b.json": {"k": "v2"}}
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(
|
||||
side_effect=[
|
||||
_make_mock_response(200, {"object": {"sha": "base-commit"}}),
|
||||
_make_mock_response(200, {"tree": {"sha": "base-tree"}}),
|
||||
_make_mock_response(200, {"tree": []}),
|
||||
]
|
||||
)
|
||||
client.post = AsyncMock(
|
||||
side_effect=[
|
||||
_make_mock_response(201, {"sha": "blob1"}),
|
||||
_make_mock_response(201, {"sha": "blob2"}),
|
||||
_make_mock_response(201, {"sha": "new-tree"}),
|
||||
_make_mock_response(201, {"sha": "new-commit"}),
|
||||
]
|
||||
)
|
||||
client.patch = AsyncMock(return_value=_make_mock_response(200, {}))
|
||||
|
||||
result = await self.backend.push_files(self.repo_url, self.token, self.branch, files, client)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["files_changed"] == 2
|
||||
commit_calls = [c for c in client.post.call_args_list if "/git/commits" in c.args[0]]
|
||||
assert len(commit_calls) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uses_gitea_api_v1_base_not_github(self):
|
||||
"""Git Data API calls target the instance's /api/v1, not api.github.com."""
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(
|
||||
side_effect=[
|
||||
_make_mock_response(200, {"object": {"sha": "base-commit"}}),
|
||||
_make_mock_response(200, {"tree": {"sha": "base-tree"}}),
|
||||
_make_mock_response(200, {"tree": []}),
|
||||
]
|
||||
)
|
||||
client.post = AsyncMock(
|
||||
side_effect=[
|
||||
_make_mock_response(201, {"sha": "blob1"}),
|
||||
_make_mock_response(201, {"sha": "new-tree"}),
|
||||
_make_mock_response(201, {"sha": "new-commit"}),
|
||||
]
|
||||
)
|
||||
client.patch = AsyncMock(return_value=_make_mock_response(200, {}))
|
||||
|
||||
await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
|
||||
|
||||
first_get_url = client.get.call_args_list[0].args[0]
|
||||
assert "git.example.com/api/v1" in first_get_url
|
||||
assert "api.github.com" not in first_get_url
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_unchanged_files(self):
|
||||
"""Files whose blob SHA matches the existing tree entry are excluded from the commit."""
|
||||
content = {"name": "my-printer"}
|
||||
sha = _blob_sha(content)
|
||||
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(
|
||||
side_effect=[
|
||||
_make_mock_response(200, {"object": {"sha": "base-commit"}}),
|
||||
_make_mock_response(200, {"tree": {"sha": "base-tree"}}),
|
||||
_make_mock_response(200, {"tree": [{"type": "blob", "path": "config/printers.json", "sha": sha}]}),
|
||||
]
|
||||
)
|
||||
|
||||
result = await self.backend.push_files(
|
||||
self.repo_url, self.token, self.branch, {"config/printers.json": content}, client
|
||||
)
|
||||
|
||||
assert result["status"] == "skipped"
|
||||
client.post.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_missing_branch_via_git_refs_api(self):
|
||||
"""A missing backup branch is created via the Git Data API refs endpoint."""
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(
|
||||
side_effect=[
|
||||
# branch ref missing
|
||||
_make_mock_response(404, {}),
|
||||
# repo info for default branch
|
||||
_make_mock_response(200, {"default_branch": "main"}),
|
||||
# default branch ref
|
||||
_make_mock_response(200, {"object": {"sha": "base-sha"}}),
|
||||
# second push_files call: branch now exists
|
||||
_make_mock_response(200, {"object": {"sha": "base-sha"}}),
|
||||
_make_mock_response(200, {"tree": {"sha": "base-tree"}}),
|
||||
_make_mock_response(200, {"tree": []}),
|
||||
]
|
||||
)
|
||||
client.post = AsyncMock(
|
||||
side_effect=[
|
||||
_make_mock_response(201, {}), # create ref
|
||||
_make_mock_response(201, {"sha": "blob1"}),
|
||||
_make_mock_response(201, {"sha": "new-tree"}),
|
||||
_make_mock_response(201, {"sha": "new-commit"}),
|
||||
]
|
||||
)
|
||||
client.patch = AsyncMock(return_value=_make_mock_response(200, {}))
|
||||
|
||||
result = await self.backend.push_files(
|
||||
self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client
|
||||
)
|
||||
|
||||
assert result["status"] == "success"
|
||||
ref_create_call = client.post.call_args_list[0]
|
||||
assert "/git/refs" in ref_create_call.args[0]
|
||||
assert ref_create_call.kwargs["json"]["ref"] == f"refs/heads/{self.branch}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_truncates_upstream_error_body_in_failure_message(self):
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(
|
||||
side_effect=[
|
||||
_make_mock_response(200, {"object": {"sha": "base-commit"}}),
|
||||
_make_mock_response(200, {"tree": {"sha": "base-tree"}}),
|
||||
_make_mock_response(200, {"tree": []}),
|
||||
]
|
||||
)
|
||||
client.post = AsyncMock(
|
||||
side_effect=[
|
||||
_make_mock_response(201, {"sha": "blob1"}),
|
||||
_make_mock_response(500, {}, text="x" * 500),
|
||||
]
|
||||
)
|
||||
|
||||
result = await self.backend.push_files(
|
||||
self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client
|
||||
)
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert result["message"] == f"Failed to create tree: {'x' * 197}..."
|
||||
|
||||
|
||||
class TestForgejoBackendApiBase:
|
||||
def setup_method(self):
|
||||
self.backend = ForgejoBackend()
|
||||
|
||||
def test_derives_api_base_from_repo_url(self):
|
||||
result = self.backend.get_api_base("https://forgejo.example.com/owner/repo")
|
||||
assert result == "https://forgejo.example.com/api/v1"
|
||||
|
||||
def test_derives_api_base_with_port(self):
|
||||
result = self.backend.get_api_base("https://forgejo.example.com:3000/owner/repo")
|
||||
assert result == "https://forgejo.example.com:3000/api/v1"
|
||||
|
||||
def test_invalid_url_raises_value_error(self):
|
||||
with pytest.raises(ValueError, match="Cannot derive API base"):
|
||||
self.backend.get_api_base("not-a-url")
|
||||
|
||||
def test_parse_url_uses_instance_host(self):
|
||||
owner, repo = self.backend.parse_repo_url("https://forgejo.example.com/owner/repo")
|
||||
assert owner == "owner"
|
||||
assert repo == "repo"
|
||||
|
||||
|
||||
class TestGitLabBackend:
|
||||
def setup_method(self):
|
||||
self.backend = GitLabBackend()
|
||||
|
||||
def test_parse_url_https(self):
|
||||
owner, repo = self.backend.parse_repo_url("https://gitlab.com/owner/repo")
|
||||
assert owner == "owner"
|
||||
assert repo == "repo"
|
||||
|
||||
def test_parse_url_ssh(self):
|
||||
owner, repo = self.backend.parse_repo_url("git@gitlab.com:owner/repo.git")
|
||||
assert owner == "owner"
|
||||
assert repo == "repo"
|
||||
|
||||
def test_parse_url_invalid_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
self.backend.parse_repo_url("not-a-url")
|
||||
|
||||
def test_get_api_base_derives_from_repo_url(self):
|
||||
result = self.backend.get_api_base("https://gitlab.com/owner/repo")
|
||||
assert result == "https://gitlab.com/api/v4"
|
||||
|
||||
def test_get_api_base_derives_from_self_hosted_url(self):
|
||||
result = self.backend.get_api_base("https://my-gitlab.example.com/owner/repo")
|
||||
assert result == "https://my-gitlab.example.com/api/v4"
|
||||
|
||||
def test_get_api_base_invalid_url_raises(self):
|
||||
with pytest.raises(ValueError, match="Cannot derive API base"):
|
||||
self.backend.get_api_base("not-a-url")
|
||||
|
||||
def test_get_headers_uses_bearer_token(self):
|
||||
headers = self.backend.get_headers("mytoken")
|
||||
assert headers["Authorization"] == "Bearer mytoken"
|
||||
assert "Content-Type" in headers
|
||||
|
||||
def test_parse_url_subgroup_https(self):
|
||||
namespace, repo = self.backend.parse_repo_url("https://gitlab.com/group/subgroup/project")
|
||||
assert namespace == "group/subgroup"
|
||||
assert repo == "project"
|
||||
|
||||
def test_parse_url_deep_namespace_https(self):
|
||||
namespace, repo = self.backend.parse_repo_url("https://gitlab.com/myorg/team/api/backend")
|
||||
assert namespace == "myorg/team/api"
|
||||
assert repo == "backend"
|
||||
|
||||
def test_parse_url_subgroup_ssh(self):
|
||||
namespace, repo = self.backend.parse_repo_url("git@gitlab.com:group/subgroup/project.git")
|
||||
assert namespace == "group/subgroup"
|
||||
assert repo == "project"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_files_encodes_subgroup_namespace_in_api_url(self):
|
||||
backend = GitLabBackend()
|
||||
repo_url = "https://gitlab.com/group/subgroup/project"
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(
|
||||
side_effect=[
|
||||
_make_mock_response(200, {"name": "bambuddy-backup"}),
|
||||
_make_mock_response(200, []),
|
||||
]
|
||||
)
|
||||
client.post = AsyncMock(return_value=_make_mock_response(201, {"id": "abc123"}))
|
||||
|
||||
await backend.push_files(repo_url, "token", "bambuddy-backup", {"f.json": {}}, client)
|
||||
|
||||
called_url = client.get.call_args_list[0].args[0]
|
||||
assert "group%2Fsubgroup%2Fproject" in called_url
|
||||
|
||||
|
||||
def _blob_sha(content: dict) -> str:
|
||||
content_bytes = json.dumps(content, indent=2, default=str).encode("utf-8")
|
||||
return hashlib.sha1(f"blob {len(content_bytes)}\0".encode() + content_bytes, usedforsecurity=False).hexdigest()
|
||||
|
||||
|
||||
def _make_mock_response(status_code: int, body=None, text: str = ""):
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.text = text
|
||||
resp.json = MagicMock(return_value=body or {})
|
||||
return resp
|
||||
|
||||
|
||||
class TestGitLabBackendPushFiles:
|
||||
def setup_method(self):
|
||||
self.backend = GitLabBackend()
|
||||
self.repo_url = "https://gitlab.com/owner/repo"
|
||||
self.token = "glpat-test"
|
||||
self.branch = "bambuddy-backup"
|
||||
self.files = {"config/printers.json": {"name": "my-printer"}}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_commit_when_content_unchanged(self):
|
||||
sha = _blob_sha(self.files["config/printers.json"])
|
||||
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(
|
||||
side_effect=[
|
||||
# branch check → branch exists
|
||||
_make_mock_response(200, {"name": self.branch}),
|
||||
# tree page 1 → one blob whose sha matches current content
|
||||
_make_mock_response(200, [{"type": "blob", "path": "config/printers.json", "id": sha}]),
|
||||
# tree page 2 → empty, stop pagination
|
||||
_make_mock_response(200, []),
|
||||
]
|
||||
)
|
||||
|
||||
result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
|
||||
|
||||
assert result["status"] == "skipped"
|
||||
assert result["files_changed"] == 0
|
||||
client.post.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commits_when_content_changed(self):
|
||||
stale_sha = "0000000000000000000000000000000000000000"
|
||||
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(
|
||||
side_effect=[
|
||||
_make_mock_response(200, {"name": self.branch}),
|
||||
_make_mock_response(200, [{"type": "blob", "path": "config/printers.json", "id": stale_sha}]),
|
||||
_make_mock_response(200, []), # page 2 empty, stop pagination
|
||||
]
|
||||
)
|
||||
client.post = AsyncMock(return_value=_make_mock_response(201, {"id": "abc123"}))
|
||||
|
||||
result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["files_changed"] == 1
|
||||
client.post.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_truncates_upstream_error_body_in_failure_message(self):
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(
|
||||
side_effect=[
|
||||
_make_mock_response(200, {"name": self.branch}),
|
||||
_make_mock_response(200, []),
|
||||
]
|
||||
)
|
||||
client.post = AsyncMock(return_value=_make_mock_response(500, {}, text="x" * 500))
|
||||
|
||||
result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert result["message"] == f"Failed to create commit: {'x' * 197}..."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_new_file_not_in_existing_tree(self):
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(
|
||||
side_effect=[
|
||||
_make_mock_response(200, {"name": self.branch}),
|
||||
# tree is empty
|
||||
_make_mock_response(200, []),
|
||||
]
|
||||
)
|
||||
client.post = AsyncMock(return_value=_make_mock_response(201, {"id": "def456"}))
|
||||
|
||||
result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
|
||||
|
||||
assert result["status"] == "success"
|
||||
call_kwargs = client.post.call_args.kwargs["json"]
|
||||
assert call_kwargs["actions"][0]["action"] == "create"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_paginates_tree_to_find_unchanged_file_on_page_2(self):
|
||||
"""Files beyond the first 100 are fetched; a file on page 2 is correctly skipped if unchanged."""
|
||||
sha = _blob_sha(self.files["config/printers.json"])
|
||||
page1_items = [{"type": "blob", "path": f"other{i}.json", "id": "aaa"} for i in range(100)]
|
||||
page2_items = (
|
||||
[{"type": "blob", "path": f"more{i}.json", "id": "bbb"} for i in range(19)]
|
||||
+ [{"type": "blob", "path": "config/printers.json", "id": sha}]
|
||||
) # 120 total blobs across two pages
|
||||
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(
|
||||
side_effect=[
|
||||
_make_mock_response(200, {"name": self.branch}), # branch check
|
||||
_make_mock_response(200, page1_items), # tree page 1
|
||||
_make_mock_response(200, page2_items), # tree page 2
|
||||
_make_mock_response(200, []), # tree page 3 empty, stop
|
||||
]
|
||||
)
|
||||
|
||||
result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
|
||||
|
||||
assert result["status"] == "skipped"
|
||||
client.post.assert_not_called()
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Unit tests for GitHub backup Pydantic schemas."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from backend.app.schemas.github_backup import GitHubBackupConfigCreate, ProviderType
|
||||
|
||||
|
||||
class TestProviderTypeEnum:
|
||||
def test_has_expected_string_values(self):
|
||||
assert ProviderType.GITHUB == "github"
|
||||
assert ProviderType.GITEA == "gitea"
|
||||
assert ProviderType.FORGEJO == "forgejo"
|
||||
assert ProviderType.GITLAB == "gitlab"
|
||||
|
||||
|
||||
class TestGitHubBackupConfigCreate:
|
||||
BASE_FIELDS = {
|
||||
"repository_url": "https://github.com/owner/repo",
|
||||
"access_token": "ghp_token",
|
||||
}
|
||||
|
||||
def test_plain_github_is_valid(self):
|
||||
config = GitHubBackupConfigCreate(**self.BASE_FIELDS)
|
||||
assert config.provider == ProviderType.GITHUB
|
||||
|
||||
def test_gitea_is_valid_without_api_base_url(self):
|
||||
config = GitHubBackupConfigCreate(
|
||||
repository_url="https://git.example.com/owner/repo",
|
||||
access_token="token",
|
||||
provider="gitea",
|
||||
)
|
||||
assert config.provider == ProviderType.GITEA
|
||||
|
||||
def test_forgejo_is_valid(self):
|
||||
config = GitHubBackupConfigCreate(
|
||||
repository_url="https://forgejo.example.com/owner/repo",
|
||||
access_token="token",
|
||||
provider="forgejo",
|
||||
)
|
||||
assert config.provider == ProviderType.FORGEJO
|
||||
|
||||
def test_url_regex_accepts_self_hosted_https_url(self):
|
||||
config = GitHubBackupConfigCreate(
|
||||
repository_url="https://git.example.com/owner/repo",
|
||||
access_token="token",
|
||||
provider="gitea",
|
||||
)
|
||||
assert "git.example.com" in config.repository_url
|
||||
|
||||
def test_url_regex_accepts_ssh_url(self):
|
||||
config = GitHubBackupConfigCreate(
|
||||
repository_url="git@github.com:owner/repo",
|
||||
access_token="ghp_token",
|
||||
)
|
||||
assert config.repository_url == "git@github.com:owner/repo"
|
||||
|
||||
def test_invalid_url_raises_validation_error(self):
|
||||
with pytest.raises(ValidationError, match="Invalid Git repository URL"):
|
||||
GitHubBackupConfigCreate(
|
||||
repository_url="not-a-url",
|
||||
access_token="ghp_token",
|
||||
)
|
||||
|
||||
def test_unknown_provider_raises_validation_error(self):
|
||||
with pytest.raises(ValidationError):
|
||||
GitHubBackupConfigCreate(
|
||||
**self.BASE_FIELDS,
|
||||
provider="not-a-provider",
|
||||
)
|
||||
|
||||
def test_http_url_without_allow_insecure_raises(self):
|
||||
with pytest.raises(ValidationError, match="Allow insecure HTTP"):
|
||||
GitHubBackupConfigCreate(
|
||||
repository_url="http://git.example.com/owner/repo",
|
||||
access_token="token",
|
||||
)
|
||||
|
||||
def test_http_url_with_allow_insecure_is_valid(self):
|
||||
config = GitHubBackupConfigCreate(
|
||||
repository_url="http://git.example.com/owner/repo",
|
||||
access_token="token",
|
||||
allow_insecure_http=True,
|
||||
)
|
||||
assert config.repository_url == "http://git.example.com/owner/repo"
|
||||
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* Tests for the provider selection UI in GitHubBackupSettings.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { render } from '../utils';
|
||||
import { GitHubBackupSettings } from '../../components/GitHubBackupSettings';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../mocks/server';
|
||||
|
||||
const baseHandlers = () => [
|
||||
http.get('/api/v1/github-backup/config', () => HttpResponse.json(null)),
|
||||
http.get('/api/v1/github-backup/status', () =>
|
||||
HttpResponse.json({
|
||||
configured: false,
|
||||
enabled: false,
|
||||
is_running: false,
|
||||
progress: null,
|
||||
last_backup_at: null,
|
||||
last_backup_status: null,
|
||||
next_scheduled_run: null,
|
||||
})
|
||||
),
|
||||
http.get('/api/v1/github-backup/logs', () => HttpResponse.json([])),
|
||||
http.get('/api/v1/local-backup/status', () =>
|
||||
HttpResponse.json({
|
||||
enabled: false,
|
||||
schedule: 'daily',
|
||||
time: '03:00',
|
||||
retention: 5,
|
||||
path: '',
|
||||
default_path: '/data/backups',
|
||||
is_running: false,
|
||||
last_backup_at: null,
|
||||
last_status: null,
|
||||
last_message: null,
|
||||
next_run: null,
|
||||
})
|
||||
),
|
||||
http.get('/api/v1/local-backup/backups', () => HttpResponse.json([])),
|
||||
http.get('/api/v1/cloud/status', () => HttpResponse.json({ is_authenticated: false })),
|
||||
http.get('/api/v1/printers', () => HttpResponse.json([])),
|
||||
http.put('/api/v1/settings/', () => HttpResponse.json({})),
|
||||
];
|
||||
|
||||
describe('GitHubBackupSettings - Provider Selection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
server.use(...baseHandlers());
|
||||
});
|
||||
|
||||
it('renders provider dropdown with GitHub selected by default', async () => {
|
||||
render(<GitHubBackupSettings />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Git Provider')).toBeInTheDocument();
|
||||
});
|
||||
const select = screen.getByRole('combobox', { name: /git provider/i });
|
||||
expect(select).toHaveValue('github');
|
||||
});
|
||||
|
||||
it('does not show instance URL field for any provider', async () => {
|
||||
render(<GitHubBackupSettings />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Git Provider')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Instance URL')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads provider from existing config', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/github-backup/config', () =>
|
||||
HttpResponse.json({
|
||||
id: 1,
|
||||
repository_url: 'https://git.example.com/owner/repo',
|
||||
has_token: true,
|
||||
branch: 'main',
|
||||
provider: 'gitea',
|
||||
schedule_enabled: false,
|
||||
schedule_type: 'daily',
|
||||
backup_kprofiles: true,
|
||||
backup_cloud_profiles: true,
|
||||
backup_settings: false,
|
||||
backup_spools: false,
|
||||
backup_archives: false,
|
||||
enabled: true,
|
||||
last_backup_at: null,
|
||||
last_backup_status: null,
|
||||
last_backup_message: null,
|
||||
last_backup_commit_sha: null,
|
||||
next_scheduled_run: null,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<GitHubBackupSettings />);
|
||||
await waitFor(() => {
|
||||
const select = screen.getByRole('combobox', { name: /git provider/i });
|
||||
expect(select).toHaveValue('gitea');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders Forgejo as a separate dropdown option', async () => {
|
||||
render(<GitHubBackupSettings />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('combobox', { name: /git provider/i })).toBeInTheDocument();
|
||||
});
|
||||
const select = screen.getByRole('combobox', { name: /git provider/i });
|
||||
const options = Array.from((select as HTMLSelectElement).options).map((o) => o.value);
|
||||
expect(options).toContain('gitea');
|
||||
expect(options).toContain('forgejo');
|
||||
});
|
||||
|
||||
it('loads forgejo provider from existing config', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/github-backup/config', () =>
|
||||
HttpResponse.json({
|
||||
id: 2,
|
||||
repository_url: 'https://forgejo.example.com/owner/repo',
|
||||
has_token: true,
|
||||
branch: 'main',
|
||||
provider: 'forgejo',
|
||||
schedule_enabled: false,
|
||||
schedule_type: 'daily',
|
||||
backup_kprofiles: true,
|
||||
backup_cloud_profiles: true,
|
||||
backup_settings: false,
|
||||
backup_spools: false,
|
||||
backup_archives: false,
|
||||
enabled: true,
|
||||
last_backup_at: null,
|
||||
last_backup_status: null,
|
||||
last_backup_message: null,
|
||||
last_backup_commit_sha: null,
|
||||
next_scheduled_run: null,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<GitHubBackupSettings />);
|
||||
await waitFor(() => {
|
||||
const select = screen.getByRole('combobox', { name: /git provider/i });
|
||||
expect(select).toHaveValue('forgejo');
|
||||
});
|
||||
});
|
||||
|
||||
it('autosaves existing config changes after debounce', async () => {
|
||||
let patchBody: Record<string, unknown> | null = null;
|
||||
|
||||
server.use(
|
||||
http.get('/api/v1/github-backup/config', () =>
|
||||
HttpResponse.json({
|
||||
id: 3,
|
||||
repository_url: 'https://git.example.com/owner/repo',
|
||||
has_token: true,
|
||||
branch: 'main',
|
||||
provider: 'gitea',
|
||||
allow_insecure_http: false,
|
||||
schedule_enabled: false,
|
||||
schedule_type: 'daily',
|
||||
backup_kprofiles: true,
|
||||
backup_cloud_profiles: true,
|
||||
backup_settings: false,
|
||||
backup_spools: false,
|
||||
backup_archives: false,
|
||||
enabled: true,
|
||||
last_backup_at: null,
|
||||
last_backup_status: null,
|
||||
last_backup_message: null,
|
||||
last_backup_commit_sha: null,
|
||||
next_scheduled_run: null,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
})
|
||||
),
|
||||
http.patch('/api/v1/github-backup/config', async ({ request }) => {
|
||||
patchBody = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({
|
||||
id: 3,
|
||||
repository_url: patchBody.repository_url,
|
||||
has_token: true,
|
||||
branch: patchBody.branch,
|
||||
provider: patchBody.provider,
|
||||
allow_insecure_http: patchBody.allow_insecure_http,
|
||||
schedule_enabled: patchBody.schedule_enabled,
|
||||
schedule_type: patchBody.schedule_type,
|
||||
backup_kprofiles: patchBody.backup_kprofiles,
|
||||
backup_cloud_profiles: patchBody.backup_cloud_profiles,
|
||||
backup_settings: patchBody.backup_settings,
|
||||
backup_spools: patchBody.backup_spools,
|
||||
backup_archives: patchBody.backup_archives,
|
||||
enabled: patchBody.enabled,
|
||||
last_backup_at: null,
|
||||
last_backup_status: null,
|
||||
last_backup_message: null,
|
||||
last_backup_commit_sha: null,
|
||||
next_scheduled_run: null,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
render(<GitHubBackupSettings />);
|
||||
|
||||
const branchInput = await screen.findByDisplayValue('main');
|
||||
await waitFor(() => expect(branchInput).toHaveValue('main'));
|
||||
|
||||
fireEvent.change(branchInput, { target: { value: 'dev' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(patchBody).toEqual({ branch: 'dev' });
|
||||
}, { timeout: 2000 });
|
||||
});
|
||||
|
||||
it('autosaves provider changes after debounce', async () => {
|
||||
let patchBody: Record<string, unknown> | null = null;
|
||||
|
||||
server.use(
|
||||
http.get('/api/v1/github-backup/config', () =>
|
||||
HttpResponse.json({
|
||||
id: 4,
|
||||
repository_url: 'https://git.example.com/owner/repo',
|
||||
has_token: true,
|
||||
branch: 'main',
|
||||
provider: 'gitea',
|
||||
allow_insecure_http: false,
|
||||
schedule_enabled: false,
|
||||
schedule_type: 'daily',
|
||||
backup_kprofiles: true,
|
||||
backup_cloud_profiles: true,
|
||||
backup_settings: false,
|
||||
backup_spools: false,
|
||||
backup_archives: false,
|
||||
enabled: true,
|
||||
last_backup_at: null,
|
||||
last_backup_status: null,
|
||||
last_backup_message: null,
|
||||
last_backup_commit_sha: null,
|
||||
next_scheduled_run: null,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
})
|
||||
),
|
||||
http.patch('/api/v1/github-backup/config', async ({ request }) => {
|
||||
patchBody = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({
|
||||
id: 4,
|
||||
repository_url: patchBody.repository_url,
|
||||
has_token: true,
|
||||
branch: patchBody.branch,
|
||||
provider: patchBody.provider,
|
||||
allow_insecure_http: patchBody.allow_insecure_http,
|
||||
schedule_enabled: patchBody.schedule_enabled,
|
||||
schedule_type: patchBody.schedule_type,
|
||||
backup_kprofiles: patchBody.backup_kprofiles,
|
||||
backup_cloud_profiles: patchBody.backup_cloud_profiles,
|
||||
backup_settings: patchBody.backup_settings,
|
||||
backup_spools: patchBody.backup_spools,
|
||||
backup_archives: patchBody.backup_archives,
|
||||
enabled: patchBody.enabled,
|
||||
last_backup_at: null,
|
||||
last_backup_status: null,
|
||||
last_backup_message: null,
|
||||
last_backup_commit_sha: null,
|
||||
next_scheduled_run: null,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
render(<GitHubBackupSettings />);
|
||||
|
||||
const providerSelect = await screen.findByRole('combobox', { name: /git provider/i });
|
||||
await waitFor(() => expect(providerSelect).toHaveValue('gitea'));
|
||||
|
||||
fireEvent.change(providerSelect, { target: { value: 'forgejo' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(patchBody).toEqual({ provider: 'forgejo' });
|
||||
}, { timeout: 2000 });
|
||||
});
|
||||
|
||||
it('autosaves repository URL changes after debounce', async () => {
|
||||
let patchBody: Record<string, unknown> | null = null;
|
||||
|
||||
server.use(
|
||||
http.get('/api/v1/github-backup/config', () =>
|
||||
HttpResponse.json({
|
||||
id: 5,
|
||||
repository_url: 'https://git.example.com/owner/repo',
|
||||
has_token: true,
|
||||
branch: 'main',
|
||||
provider: 'gitea',
|
||||
allow_insecure_http: false,
|
||||
schedule_enabled: false,
|
||||
schedule_type: 'daily',
|
||||
backup_kprofiles: true,
|
||||
backup_cloud_profiles: true,
|
||||
backup_settings: false,
|
||||
backup_spools: false,
|
||||
backup_archives: false,
|
||||
enabled: true,
|
||||
last_backup_at: null,
|
||||
last_backup_status: null,
|
||||
last_backup_message: null,
|
||||
last_backup_commit_sha: null,
|
||||
next_scheduled_run: null,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
})
|
||||
),
|
||||
http.patch('/api/v1/github-backup/config', async ({ request }) => {
|
||||
patchBody = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({
|
||||
id: 5,
|
||||
repository_url: patchBody.repository_url,
|
||||
has_token: true,
|
||||
branch: patchBody.branch,
|
||||
provider: patchBody.provider,
|
||||
allow_insecure_http: patchBody.allow_insecure_http,
|
||||
schedule_enabled: patchBody.schedule_enabled,
|
||||
schedule_type: patchBody.schedule_type,
|
||||
backup_kprofiles: patchBody.backup_kprofiles,
|
||||
backup_cloud_profiles: patchBody.backup_cloud_profiles,
|
||||
backup_settings: patchBody.backup_settings,
|
||||
backup_spools: patchBody.backup_spools,
|
||||
backup_archives: patchBody.backup_archives,
|
||||
enabled: patchBody.enabled,
|
||||
last_backup_at: null,
|
||||
last_backup_status: null,
|
||||
last_backup_message: null,
|
||||
last_backup_commit_sha: null,
|
||||
next_scheduled_run: null,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
render(<GitHubBackupSettings />);
|
||||
|
||||
const repoInput = await screen.findByDisplayValue('https://git.example.com/owner/repo');
|
||||
fireEvent.change(repoInput, { target: { value: 'https://git.example.com/owner/other-repo' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(patchBody).toEqual({
|
||||
repository_url: 'https://git.example.com/owner/other-repo',
|
||||
});
|
||||
}, { timeout: 2000 });
|
||||
});
|
||||
|
||||
it('does not let pending token autosave cancel provider settings autosave', async () => {
|
||||
let patchBody: Record<string, unknown> | null = null;
|
||||
let postBody: Record<string, unknown> | null = null;
|
||||
|
||||
server.use(
|
||||
http.get('/api/v1/github-backup/config', () =>
|
||||
HttpResponse.json({
|
||||
id: 6,
|
||||
repository_url: 'http://git.example.com/owner/repo',
|
||||
has_token: true,
|
||||
branch: 'main',
|
||||
provider: 'gitea',
|
||||
allow_insecure_http: true,
|
||||
schedule_enabled: false,
|
||||
schedule_type: 'daily',
|
||||
backup_kprofiles: true,
|
||||
backup_cloud_profiles: true,
|
||||
backup_settings: false,
|
||||
backup_spools: false,
|
||||
backup_archives: false,
|
||||
enabled: true,
|
||||
last_backup_at: null,
|
||||
last_backup_status: null,
|
||||
last_backup_message: null,
|
||||
last_backup_commit_sha: null,
|
||||
next_scheduled_run: null,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
})
|
||||
),
|
||||
http.patch('/api/v1/github-backup/config', async ({ request }) => {
|
||||
patchBody = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({
|
||||
id: 6,
|
||||
repository_url: 'http://git.example.com/owner/repo',
|
||||
has_token: true,
|
||||
branch: 'main',
|
||||
provider: patchBody.provider ?? 'gitea',
|
||||
allow_insecure_http: true,
|
||||
schedule_enabled: false,
|
||||
schedule_type: 'daily',
|
||||
backup_kprofiles: true,
|
||||
backup_cloud_profiles: true,
|
||||
backup_settings: false,
|
||||
backup_spools: false,
|
||||
backup_archives: false,
|
||||
enabled: true,
|
||||
last_backup_at: null,
|
||||
last_backup_status: null,
|
||||
last_backup_message: null,
|
||||
last_backup_commit_sha: null,
|
||||
next_scheduled_run: null,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
});
|
||||
}),
|
||||
http.post('/api/v1/github-backup/config', async ({ request }) => {
|
||||
postBody = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({
|
||||
id: 6,
|
||||
repository_url: postBody.repository_url,
|
||||
has_token: true,
|
||||
branch: postBody.branch,
|
||||
provider: postBody.provider,
|
||||
allow_insecure_http: postBody.allow_insecure_http,
|
||||
schedule_enabled: postBody.schedule_enabled,
|
||||
schedule_type: postBody.schedule_type,
|
||||
backup_kprofiles: postBody.backup_kprofiles,
|
||||
backup_cloud_profiles: postBody.backup_cloud_profiles,
|
||||
backup_settings: postBody.backup_settings,
|
||||
backup_spools: postBody.backup_spools,
|
||||
backup_archives: postBody.backup_archives,
|
||||
enabled: postBody.enabled,
|
||||
last_backup_at: null,
|
||||
last_backup_status: null,
|
||||
last_backup_message: null,
|
||||
last_backup_commit_sha: null,
|
||||
next_scheduled_run: null,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
render(<GitHubBackupSettings />);
|
||||
|
||||
const tokenInput = await screen.findByPlaceholderText('Enter new token to update');
|
||||
fireEvent.change(tokenInput, { target: { value: 'new-token' } });
|
||||
|
||||
const providerSelect = await screen.findByRole('combobox', { name: /git provider/i });
|
||||
fireEvent.change(providerSelect, { target: { value: 'forgejo' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(patchBody).toEqual({ provider: 'forgejo' });
|
||||
}, { timeout: 2000 });
|
||||
});
|
||||
});
|
||||
@@ -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' }
|
||||
),
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
GitHubBackupLog,
|
||||
GitHubBackupStatus,
|
||||
GitHubBackupTriggerResponse,
|
||||
GitProviderType,
|
||||
LocalBackupFile,
|
||||
LocalBackupStatus,
|
||||
ScheduleType,
|
||||
@@ -76,6 +77,56 @@ function StatusBadge({ status }: StatusBadgeProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const PROVIDER_REPO_URL_I18N_KEY: Record<GitProviderType, string> = {
|
||||
github: 'backup.repoUrlPlaceholderGitHub',
|
||||
gitea: 'backup.repoUrlPlaceholderGitea',
|
||||
forgejo: 'backup.repoUrlPlaceholderForgejo',
|
||||
gitlab: 'backup.repoUrlPlaceholderGitLab',
|
||||
};
|
||||
|
||||
const PROVIDER_TOKEN_PLACEHOLDER: Record<GitProviderType, string> = {
|
||||
github: 'ghp_xxxxxxxxxxxx',
|
||||
gitea: 'your_access_token',
|
||||
forgejo: 'your_access_token',
|
||||
gitlab: 'glpat-xxxxxxxxxxxx',
|
||||
};
|
||||
|
||||
interface GitHubBackupAutosaveState {
|
||||
repository_url: string;
|
||||
branch: string;
|
||||
provider: GitProviderType;
|
||||
allow_insecure_http: boolean;
|
||||
schedule_enabled: boolean;
|
||||
schedule_type: ScheduleType;
|
||||
backup_kprofiles: boolean;
|
||||
backup_cloud_profiles: boolean;
|
||||
backup_settings: boolean;
|
||||
backup_spools: boolean;
|
||||
backup_archives: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
function serializeAutosaveState(state: GitHubBackupAutosaveState): string {
|
||||
return JSON.stringify(state);
|
||||
}
|
||||
|
||||
function getChangedAutosaveFields(
|
||||
current: GitHubBackupAutosaveState,
|
||||
previous: GitHubBackupAutosaveState | null
|
||||
): Partial<GitHubBackupAutosaveState> {
|
||||
if (!previous) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const changes: Partial<GitHubBackupAutosaveState> = {};
|
||||
for (const key of Object.keys(current) as Array<keyof GitHubBackupAutosaveState>) {
|
||||
if (current[key] !== previous[key]) {
|
||||
changes[key] = current[key] as never;
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
export function GitHubBackupSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
@@ -85,6 +136,7 @@ export function GitHubBackupSettings() {
|
||||
const [repoUrl, setRepoUrl] = useState('');
|
||||
const [accessToken, setAccessToken] = useState('');
|
||||
const [branch, setBranch] = useState('main');
|
||||
const [provider, setProvider] = useState<GitProviderType>('github');
|
||||
const [scheduleEnabled, setScheduleEnabled] = useState(false);
|
||||
const [scheduleType, setScheduleType] = useState<ScheduleType>('daily');
|
||||
const [backupKProfiles, setBackupKProfiles] = useState(true);
|
||||
@@ -92,6 +144,7 @@ export function GitHubBackupSettings() {
|
||||
const [backupSettings, setBackupSettings] = useState(false);
|
||||
const [backupSpools, setBackupSpools] = useState(false);
|
||||
const [backupArchives, setBackupArchives] = useState(false);
|
||||
const [allowInsecureHttp, setAllowInsecureHttp] = useState(false);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
|
||||
// Local backup state
|
||||
@@ -198,8 +251,32 @@ export function GitHubBackupSettings() {
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
// Auto-save debounce
|
||||
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isInitializedRef = useRef(false);
|
||||
const settingsAutoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const tokenAutoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const initializationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastSavedAutosaveStateRef = useRef<GitHubBackupAutosaveState | null>(null);
|
||||
const lastTokenScheduledForSaveRef = useRef('');
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
const autoSaveState = useMemo<GitHubBackupAutosaveState>(() => ({
|
||||
repository_url: repoUrl,
|
||||
branch,
|
||||
provider,
|
||||
allow_insecure_http: allowInsecureHttp,
|
||||
schedule_enabled: scheduleEnabled,
|
||||
schedule_type: scheduleType,
|
||||
backup_kprofiles: backupKProfiles,
|
||||
backup_cloud_profiles: backupCloudProfiles,
|
||||
backup_settings: backupSettings,
|
||||
backup_spools: backupSpools,
|
||||
backup_archives: backupArchives,
|
||||
enabled,
|
||||
}), [repoUrl, branch, provider, allowInsecureHttp, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, backupSpools, backupArchives, enabled]);
|
||||
|
||||
const autoSaveStateFingerprint = useMemo(
|
||||
() => serializeAutosaveState(autoSaveState),
|
||||
[autoSaveState]
|
||||
);
|
||||
|
||||
// Queries
|
||||
const { data: config, isLoading: configLoading } = useQuery<GitHubBackupConfig | null>({
|
||||
@@ -251,9 +328,29 @@ export function GitHubBackupSettings() {
|
||||
|
||||
// Initialize form from config
|
||||
useEffect(() => {
|
||||
if (initializationTimerRef.current) {
|
||||
clearTimeout(initializationTimerRef.current);
|
||||
}
|
||||
|
||||
if (config) {
|
||||
setIsInitialized(false);
|
||||
lastSavedAutosaveStateRef.current = {
|
||||
repository_url: config.repository_url,
|
||||
branch: config.branch,
|
||||
provider: config.provider ?? 'github',
|
||||
allow_insecure_http: config.allow_insecure_http ?? false,
|
||||
schedule_enabled: config.schedule_enabled,
|
||||
schedule_type: config.schedule_type,
|
||||
backup_kprofiles: config.backup_kprofiles,
|
||||
backup_cloud_profiles: config.backup_cloud_profiles,
|
||||
backup_settings: config.backup_settings,
|
||||
backup_spools: config.backup_spools,
|
||||
backup_archives: config.backup_archives,
|
||||
enabled: config.enabled,
|
||||
};
|
||||
setRepoUrl(config.repository_url);
|
||||
setBranch(config.branch);
|
||||
setProvider(config.provider ?? 'github');
|
||||
setScheduleEnabled(config.schedule_enabled);
|
||||
setScheduleType(config.schedule_type);
|
||||
setBackupKProfiles(config.backup_kprofiles);
|
||||
@@ -261,95 +358,103 @@ export function GitHubBackupSettings() {
|
||||
setBackupSettings(config.backup_settings);
|
||||
setBackupSpools(config.backup_spools);
|
||||
setBackupArchives(config.backup_archives);
|
||||
setAllowInsecureHttp(config.allow_insecure_http ?? false);
|
||||
setEnabled(config.enabled);
|
||||
setAccessToken(''); // Don't show stored token
|
||||
// Mark as initialized after a tick to avoid auto-save on initial load
|
||||
setTimeout(() => { isInitializedRef.current = true; }, 100);
|
||||
initializationTimerRef.current = setTimeout(() => { setIsInitialized(true); }, 100);
|
||||
} else {
|
||||
setIsInitialized(false);
|
||||
lastSavedAutosaveStateRef.current = null;
|
||||
setAccessToken('');
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (initializationTimerRef.current) {
|
||||
clearTimeout(initializationTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
// Auto-save function for existing configs
|
||||
const autoSave = useCallback(async (includeToken: boolean = false) => {
|
||||
if (!config?.has_token) return; // Only auto-save if config already exists
|
||||
if (!config) return; // Only auto-save if config already exists
|
||||
|
||||
try {
|
||||
if (includeToken && accessToken) {
|
||||
// Full save with new token
|
||||
await api.saveGitHubBackupConfig({
|
||||
repository_url: repoUrl,
|
||||
...autoSaveState,
|
||||
access_token: accessToken,
|
||||
branch,
|
||||
schedule_enabled: scheduleEnabled,
|
||||
schedule_type: scheduleType,
|
||||
backup_kprofiles: backupKProfiles,
|
||||
backup_cloud_profiles: backupCloudProfiles,
|
||||
backup_settings: backupSettings,
|
||||
backup_spools: backupSpools,
|
||||
backup_archives: backupArchives,
|
||||
enabled,
|
||||
});
|
||||
setAccessToken(''); // Clear after save
|
||||
showToast(t('backup.tokenUpdated'));
|
||||
lastTokenScheduledForSaveRef.current = '';
|
||||
} else {
|
||||
// Update without token
|
||||
await api.updateGitHubBackupConfig({
|
||||
repository_url: repoUrl,
|
||||
branch,
|
||||
schedule_enabled: scheduleEnabled,
|
||||
schedule_type: scheduleType,
|
||||
backup_kprofiles: backupKProfiles,
|
||||
backup_cloud_profiles: backupCloudProfiles,
|
||||
backup_settings: backupSettings,
|
||||
backup_spools: backupSpools,
|
||||
backup_archives: backupArchives,
|
||||
enabled,
|
||||
});
|
||||
await api.updateGitHubBackupConfig(getChangedAutosaveFields(
|
||||
autoSaveState,
|
||||
lastSavedAutosaveStateRef.current
|
||||
));
|
||||
showToast(t('backup.settingsSaved'));
|
||||
}
|
||||
lastSavedAutosaveStateRef.current = autoSaveState;
|
||||
queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
|
||||
} catch (error) {
|
||||
showToast(t('backup.failedToSave', { message: (error as Error).message }), 'error');
|
||||
}
|
||||
}, [config?.has_token, repoUrl, accessToken, branch, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, backupSpools, backupArchives, enabled, queryClient, showToast, t]);
|
||||
}, [config, accessToken, autoSaveState, queryClient, showToast, t]);
|
||||
|
||||
const autoSaveRef = useRef(autoSave);
|
||||
|
||||
useEffect(() => {
|
||||
autoSaveRef.current = autoSave;
|
||||
}, [autoSave]);
|
||||
|
||||
// Auto-save effect for existing configs (debounced)
|
||||
useEffect(() => {
|
||||
if (!isInitializedRef.current || !config?.has_token) return;
|
||||
if (!isInitialized || !config) return;
|
||||
if (
|
||||
lastSavedAutosaveStateRef.current
|
||||
&& autoSaveStateFingerprint === serializeAutosaveState(lastSavedAutosaveStateRef.current)
|
||||
) return;
|
||||
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
if (settingsAutoSaveTimerRef.current) {
|
||||
clearTimeout(settingsAutoSaveTimerRef.current);
|
||||
}
|
||||
|
||||
autoSaveTimerRef.current = setTimeout(() => {
|
||||
settingsAutoSaveTimerRef.current = setTimeout(() => {
|
||||
autoSave(false);
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
if (settingsAutoSaveTimerRef.current) {
|
||||
clearTimeout(settingsAutoSaveTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [repoUrl, branch, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, backupSpools, backupArchives, enabled, autoSave, config?.has_token]);
|
||||
}, [isInitialized, config, autoSaveStateFingerprint, autoSave]);
|
||||
|
||||
// Auto-save token when it changes (with longer debounce)
|
||||
useEffect(() => {
|
||||
if (!isInitializedRef.current || !config?.has_token || !accessToken) return;
|
||||
if (!isInitialized || !config || !accessToken) return;
|
||||
if (accessToken === lastTokenScheduledForSaveRef.current) return;
|
||||
lastTokenScheduledForSaveRef.current = accessToken;
|
||||
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
if (tokenAutoSaveTimerRef.current) {
|
||||
clearTimeout(tokenAutoSaveTimerRef.current);
|
||||
}
|
||||
|
||||
autoSaveTimerRef.current = setTimeout(() => {
|
||||
autoSave(true);
|
||||
tokenAutoSaveTimerRef.current = setTimeout(() => {
|
||||
autoSaveRef.current(true);
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
if (tokenAutoSaveTimerRef.current) {
|
||||
clearTimeout(tokenAutoSaveTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [accessToken, autoSave, config?.has_token]);
|
||||
}, [isInitialized, accessToken, config]);
|
||||
|
||||
// Mutations
|
||||
const saveConfigMutation = useMutation({
|
||||
@@ -359,7 +464,7 @@ export function GitHubBackupSettings() {
|
||||
queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
|
||||
showToast(t('backup.githubBackupEnabled'));
|
||||
setAccessToken('');
|
||||
isInitializedRef.current = true;
|
||||
setIsInitialized(true);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(t('backup.failedToSave', { message: error.message }), 'error');
|
||||
@@ -409,7 +514,7 @@ export function GitHubBackupSettings() {
|
||||
setTestLoading(false);
|
||||
return;
|
||||
}
|
||||
result = await api.testGitHubConnection(repoUrl, accessToken);
|
||||
result = await api.testGitHubConnection(repoUrl, accessToken, provider);
|
||||
} else if (config?.has_token) {
|
||||
// Use stored credentials
|
||||
result = await api.testGitHubStoredConnection();
|
||||
@@ -441,6 +546,8 @@ export function GitHubBackupSettings() {
|
||||
repository_url: repoUrl,
|
||||
access_token: accessToken,
|
||||
branch,
|
||||
provider,
|
||||
allow_insecure_http: allowInsecureHttp,
|
||||
schedule_enabled: scheduleEnabled,
|
||||
schedule_type: scheduleType,
|
||||
backup_kprofiles: backupKProfiles,
|
||||
@@ -462,7 +569,7 @@ export function GitHubBackupSettings() {
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Left Column - GitHub Backup */}
|
||||
{/* Left Column - Git Backup */}
|
||||
<div className="space-y-6">
|
||||
<Card id="card-backup-github">
|
||||
<CardHeader>
|
||||
@@ -487,6 +594,22 @@ export function GitHubBackupSettings() {
|
||||
{t('backup.githubDescription')}
|
||||
</p>
|
||||
|
||||
{/* Provider Selection */}
|
||||
<div>
|
||||
<label htmlFor="git-provider-select" className="block text-sm text-bambu-gray mb-1">{t('backup.provider')}</label>
|
||||
<select
|
||||
id="git-provider-select"
|
||||
value={provider}
|
||||
onChange={(e) => { setProvider(e.target.value as GitProviderType); setTestResult(null); }}
|
||||
className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
<option value="github">{t('backup.providerGitHub')}</option>
|
||||
<option value="gitlab">{t('backup.providerGitLab')}</option>
|
||||
<option value="gitea">{t('backup.providerGitea')}</option>
|
||||
<option value="forgejo">{t('backup.providerForgejo')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Repository URL */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">
|
||||
@@ -496,9 +619,21 @@ export function GitHubBackupSettings() {
|
||||
type="text"
|
||||
value={repoUrl}
|
||||
onChange={(e) => { setRepoUrl(e.target.value); setTestResult(null); }}
|
||||
placeholder="https://github.com/username/bambuddy-backup"
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
placeholder={t(PROVIDER_REPO_URL_I18N_KEY[provider])}
|
||||
className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
/>
|
||||
<label className="flex items-start gap-2 mt-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowInsecureHttp}
|
||||
onChange={(e) => { setAllowInsecureHttp(e.target.checked); setTestResult(null); }}
|
||||
className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm text-white">{t('backup.allowInsecureHttp')}</span>
|
||||
<p className="text-xs text-bambu-gray">{t('backup.allowInsecureHttpHint')}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Access Token */}
|
||||
@@ -510,8 +645,8 @@ export function GitHubBackupSettings() {
|
||||
type="password"
|
||||
value={accessToken}
|
||||
onChange={(e) => { setAccessToken(e.target.value); setTestResult(null); }}
|
||||
placeholder={config?.has_token ? t('backup.enterNewToken') : 'ghp_xxxxxxxxxxxx'}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
placeholder={config?.has_token ? t('backup.enterNewToken') : PROVIDER_TOKEN_PLACEHOLDER[provider]}
|
||||
className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
/>
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
{t('backup.tokenHint')}
|
||||
@@ -527,7 +662,7 @@ export function GitHubBackupSettings() {
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
placeholder="main"
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -542,7 +677,7 @@ export function GitHubBackupSettings() {
|
||||
setScheduleType(e.target.value as ScheduleType);
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
<option value="disabled">{t('backup.manualOnly')}</option>
|
||||
<option value="hourly">{t('backup.hourly')}</option>
|
||||
@@ -958,7 +1093,7 @@ export function GitHubBackupSettings() {
|
||||
<label className="block text-sm text-bambu-gray mb-1">{t('backup.frequency')}</label>
|
||||
<select
|
||||
value={localBackupStatus?.schedule ?? 'daily'}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
onChange={async (e) => {
|
||||
try {
|
||||
await api.updateSettings({ local_backup_schedule: e.target.value });
|
||||
@@ -980,7 +1115,7 @@ export function GitHubBackupSettings() {
|
||||
<input
|
||||
type="time"
|
||||
value={localBackupStatus?.time ?? '03:00'}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none [color-scheme:dark]"
|
||||
className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none [color-scheme:dark]"
|
||||
onChange={async (e) => {
|
||||
try {
|
||||
await api.updateSettings({ local_backup_time: e.target.value });
|
||||
@@ -1001,7 +1136,7 @@ export function GitHubBackupSettings() {
|
||||
min={1}
|
||||
max={100}
|
||||
value={localBackupStatus?.retention ?? 5}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
onChange={async (e) => {
|
||||
const val = Math.max(1, Math.min(100, parseInt(e.target.value) || 5));
|
||||
try {
|
||||
@@ -1024,7 +1159,7 @@ export function GitHubBackupSettings() {
|
||||
type="text"
|
||||
value={localBackupPath}
|
||||
onChange={(e) => setLocalBackupPath(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
onBlur={async () => {
|
||||
try {
|
||||
await api.updateSettings({ local_backup_path: localBackupPath });
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'バックアップを復元',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '恢复备份',
|
||||
|
||||
@@ -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: '恢復備份',
|
||||
|
||||
Reference in New Issue
Block a user