| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371 |
- """API routes for GitHub profile backup."""
- import logging
- from fastapi import APIRouter, Depends, HTTPException, Query
- from sqlalchemy import delete, desc, select
- from sqlalchemy.ext.asyncio import AsyncSession
- from backend.app.core.auth import RequirePermissionIfAuthEnabled
- from backend.app.core.database import get_db
- from backend.app.core.permissions import Permission
- from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
- from backend.app.models.user import User
- from backend.app.schemas.github_backup import (
- GitHubBackupConfigCreate,
- GitHubBackupConfigResponse,
- GitHubBackupConfigUpdate,
- GitHubBackupLogResponse,
- GitHubBackupStatus,
- GitHubBackupTriggerResponse,
- GitHubTestConnectionResponse,
- ProviderType,
- )
- from backend.app.services.github_backup import github_backup_service
- logger = logging.getLogger(__name__)
- router = APIRouter(prefix="/github-backup", tags=["github-backup"])
- def _config_to_response(config: GitHubBackupConfig) -> dict:
- """Convert config model to response dict."""
- return {
- "id": config.id,
- "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,
- "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,
- "last_backup_at": config.last_backup_at,
- "last_backup_status": config.last_backup_status,
- "last_backup_message": config.last_backup_message,
- "last_backup_commit_sha": config.last_backup_commit_sha,
- "next_scheduled_run": config.next_scheduled_run,
- "created_at": config.created_at,
- "updated_at": config.updated_at,
- }
- @router.get("/config", response_model=GitHubBackupConfigResponse | None)
- async def get_config(
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
- ):
- """Get the current GitHub backup configuration."""
- result = await db.execute(select(GitHubBackupConfig).limit(1))
- config = result.scalar_one_or_none()
- if not config:
- return None
- return _config_to_response(config)
- @router.post("/config", response_model=GitHubBackupConfigResponse)
- async def save_config(
- config_data: GitHubBackupConfigCreate,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
- ):
- """Create or update GitHub backup configuration.
- Only one configuration is supported. If one exists, it will be updated.
- """
- # Check for existing config
- result = await db.execute(select(GitHubBackupConfig).limit(1))
- config = result.scalar_one_or_none()
- if config:
- # Update existing
- 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
- config.backup_cloud_profiles = config_data.backup_cloud_profiles
- 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)
- else:
- config.next_scheduled_run = None
- logger.info("Updated GitHub backup config: %s", config.repository_url)
- else:
- # Create new
- config = GitHubBackupConfig(
- 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,
- backup_cloud_profiles=config_data.backup_cloud_profiles,
- 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)
- db.add(config)
- logger.info("Created GitHub backup config: %s", config.repository_url)
- await db.commit()
- await db.refresh(config)
- return _config_to_response(config)
- @router.patch("/config", response_model=GitHubBackupConfigResponse)
- async def update_config(
- update_data: GitHubBackupConfigUpdate,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
- ):
- """Partially update GitHub backup configuration."""
- result = await db.execute(select(GitHubBackupConfig).limit(1))
- config = result.scalar_one_or_none()
- if not config:
- raise HTTPException(status_code=404, detail="No configuration found")
- 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 in ("schedule_type", "provider") and value is not None:
- setattr(config, key, value.value)
- else:
- setattr(config, key, value)
- # 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)
- else:
- config.next_scheduled_run = None
- await db.commit()
- await db.refresh(config)
- logger.info("Updated GitHub backup config: %s", config.repository_url)
- return _config_to_response(config)
- @router.delete("/config")
- async def delete_config(
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
- ):
- """Delete the GitHub backup configuration and all logs."""
- result = await db.execute(select(GitHubBackupConfig).limit(1))
- config = result.scalar_one_or_none()
- if not config:
- raise HTTPException(status_code=404, detail="No configuration found")
- await db.delete(config)
- await db.commit()
- logger.info("Deleted GitHub backup config")
- return {"message": "Configuration deleted"}
- @router.post("/test", response_model=GitHubTestConnectionResponse)
- async def test_connection(
- 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 Git provider connection with provided credentials."""
- result = await github_backup_service.test_connection(repo_url, token, provider=provider)
- return GitHubTestConnectionResponse(**result)
- @router.post("/test-stored", response_model=GitHubTestConnectionResponse)
- async def test_stored_connection(
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
- ):
- """Test GitHub connection using stored configuration."""
- result = await db.execute(select(GitHubBackupConfig).limit(1))
- config = result.scalar_one_or_none()
- if not config:
- raise HTTPException(status_code=404, detail="No configuration found")
- 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,
- provider=config.provider,
- )
- return GitHubTestConnectionResponse(**test_result)
- @router.post("/run", response_model=GitHubBackupTriggerResponse)
- async def trigger_backup(
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
- ):
- """Manually trigger a backup."""
- result = await db.execute(select(GitHubBackupConfig).limit(1))
- config = result.scalar_one_or_none()
- if not config:
- raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
- if not config.enabled:
- raise HTTPException(status_code=400, detail="Backup is disabled")
- backup_result = await github_backup_service.run_backup(config.id, trigger="manual")
- return GitHubBackupTriggerResponse(**backup_result)
- @router.get("/status", response_model=GitHubBackupStatus)
- async def get_status(
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
- ):
- """Get current backup status."""
- result = await db.execute(select(GitHubBackupConfig).limit(1))
- config = result.scalar_one_or_none()
- if not config:
- return GitHubBackupStatus(
- configured=False,
- enabled=False,
- is_running=False,
- progress=None,
- last_backup_at=None,
- last_backup_status=None,
- next_scheduled_run=None,
- )
- return GitHubBackupStatus(
- configured=True,
- enabled=config.enabled,
- is_running=github_backup_service.is_running,
- progress=github_backup_service.progress,
- last_backup_at=config.last_backup_at,
- last_backup_status=config.last_backup_status,
- next_scheduled_run=config.next_scheduled_run,
- )
- @router.get("/logs", response_model=list[GitHubBackupLogResponse])
- async def get_logs(
- limit: int = Query(default=50, ge=1, le=200),
- offset: int = Query(default=0, ge=0),
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
- ):
- """Get backup logs."""
- result = await db.execute(select(GitHubBackupConfig).limit(1))
- config = result.scalar_one_or_none()
- if not config:
- return []
- logs_result = await db.execute(
- select(GitHubBackupLog)
- .where(GitHubBackupLog.config_id == config.id)
- .order_by(desc(GitHubBackupLog.started_at))
- .offset(offset)
- .limit(limit)
- )
- logs = logs_result.scalars().all()
- return [
- GitHubBackupLogResponse(
- id=log.id,
- config_id=log.config_id,
- started_at=log.started_at,
- completed_at=log.completed_at,
- status=log.status,
- trigger=log.trigger,
- commit_sha=log.commit_sha,
- files_changed=log.files_changed,
- error_message=log.error_message,
- )
- for log in logs
- ]
- @router.delete("/logs")
- async def clear_logs(
- keep_last: int = Query(default=10, ge=0, le=100, description="Number of recent logs to keep"),
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
- ):
- """Clear backup logs, optionally keeping the most recent entries."""
- result = await db.execute(select(GitHubBackupConfig).limit(1))
- config = result.scalar_one_or_none()
- if not config:
- return {"deleted": 0, "message": "No configuration found"}
- if keep_last > 0:
- # Get IDs to keep
- keep_result = await db.execute(
- select(GitHubBackupLog.id)
- .where(GitHubBackupLog.config_id == config.id)
- .order_by(desc(GitHubBackupLog.started_at))
- .limit(keep_last)
- )
- keep_ids = [row[0] for row in keep_result.fetchall()]
- if keep_ids:
- delete_result = await db.execute(
- delete(GitHubBackupLog).where(
- GitHubBackupLog.config_id == config.id, GitHubBackupLog.id.not_in(keep_ids)
- )
- )
- else:
- delete_result = await db.execute(delete(GitHubBackupLog).where(GitHubBackupLog.config_id == config.id))
- else:
- delete_result = await db.execute(delete(GitHubBackupLog).where(GitHubBackupLog.config_id == config.id))
- await db.commit()
- deleted_count = delete_result.rowcount
- logger.info("Deleted %s GitHub backup logs (kept %s)", deleted_count, keep_last)
- return {"deleted": deleted_count, "message": f"Deleted {deleted_count} logs"}
|