import difflib from datetime import date, datetime, time, timedelta, timezone from pathlib import Path from fastapi import HTTPException from sqlalchemy.orm import Session, joinedload from app.models.backup import Backup from app.models.router import Router from app.models.user import User from app.services.file_service import compute_checksum, ensure_data_dir from app.services.log_service import log_service from app.services.notification_service import notification_service from app.services.router_service import router_service from app.services.settings_service import settings_service class BackupService: def _device_label(self, router: Router) -> str: platform = 'SwitchOS' if router.device_type == 'switchos' else 'RouterOS' return f'{platform} device {router.name}' def _router_for_user(self, db: Session, user: User, router_id: int) -> Router: router = db.query(Router).filter(Router.id == router_id, Router.owner_id == user.id).first() if not router: raise HTTPException(status_code=404, detail='Device not found') return router def _serialize_backup(self, backup: Backup): file_path = Path(backup.file_path) return { 'id': backup.id, 'router_id': backup.router_id, 'router_name': backup.router.name if backup.router else None, 'device_type': backup.router.device_type if backup.router else 'routeros', 'file_path': backup.file_path, 'file_name': backup.file_name, 'backup_type': backup.backup_type, 'checksum': backup.checksum, 'file_size': file_path.stat().st_size if file_path.exists() else None, 'created_at': backup.created_at, } def _build_structured_diff(self, left_lines: list[str], right_lines: list[str]): matcher = difflib.SequenceMatcher(a=left_lines, b=right_lines) rows = [] stats = {'added': 0, 'removed': 0, 'modified': 0, 'context': 0} left_number = 1 right_number = 1 for tag, i1, i2, j1, j2 in matcher.get_opcodes(): if tag == 'equal': for left_text, right_text in zip(left_lines[i1:i2], right_lines[j1:j2]): rows.append( { 'type': 'context', 'left_number': left_number, 'right_number': right_number, 'left_text': left_text, 'right_text': right_text, } ) stats['context'] += 1 left_number += 1 right_number += 1 continue if tag == 'delete': for left_text in left_lines[i1:i2]: rows.append( { 'type': 'removed', 'left_number': left_number, 'right_number': None, 'left_text': left_text, 'right_text': '', } ) stats['removed'] += 1 left_number += 1 continue if tag == 'insert': for right_text in right_lines[j1:j2]: rows.append( { 'type': 'added', 'left_number': None, 'right_number': right_number, 'left_text': '', 'right_text': right_text, } ) stats['added'] += 1 right_number += 1 continue block_left = left_lines[i1:i2] block_right = right_lines[j1:j2] block_size = max(len(block_left), len(block_right)) for index in range(block_size): left_text = block_left[index] if index < len(block_left) else '' right_text = block_right[index] if index < len(block_right) else '' row_type = 'modified' if left_text and not right_text: row_type = 'removed' stats['removed'] += 1 elif right_text and not left_text: row_type = 'added' stats['added'] += 1 else: stats['modified'] += 1 rows.append( { 'type': row_type, 'left_number': left_number if left_text else None, 'right_number': right_number if right_text else None, 'left_text': left_text, 'right_text': right_text, } ) if left_text: left_number += 1 if right_text: right_number += 1 return rows, stats def get_backup_for_user(self, db: Session, user: User, backup_id: int) -> Backup: backup = ( db.query(Backup) .options(joinedload(Backup.router)) .join(Router) .filter(Backup.id == backup_id, Router.owner_id == user.id) .first() ) if not backup: raise HTTPException(status_code=404, detail='Backup not found') return backup def list_backups( self, db: Session, user: User, search: str | None = None, backup_type: str | None = None, router_id: int | None = None, created_on: date | None = None, sort_by: str = 'created_at', order: str = 'desc', ): query = db.query(Backup).options(joinedload(Backup.router)).join(Router).filter(Router.owner_id == user.id) if search: query = query.filter( Backup.file_name.ilike(f'%{search}%') | Router.name.ilike(f'%{search}%') | Router.host.ilike(f'%{search}%') ) if backup_type: query = query.filter(Backup.backup_type == backup_type) if router_id: query = query.filter(Backup.router_id == router_id) if created_on: day_start = datetime.combine(created_on, time.min) next_day = day_start + timedelta(days=1) query = query.filter(Backup.created_at >= day_start, Backup.created_at < next_day) sort_map = { 'created_at': Backup.created_at, 'file_name': Backup.file_name, 'backup_type': Backup.backup_type, 'router_name': Router.name, } sort_column = sort_map.get(sort_by, Backup.created_at) query = query.order_by(sort_column.asc() if order == 'asc' else sort_column.desc()) return [self._serialize_backup(backup) for backup in query.all()] def list_router_backups(self, db: Session, user: User, router_id: int): router = self._router_for_user(db, user, router_id) backups = ( db.query(Backup) .options(joinedload(Backup.router)) .filter(Backup.router_id == router.id) .order_by(Backup.created_at.desc()) .all() ) return [self._serialize_backup(backup) for backup in backups] def export_router(self, db: Session, user: User, router_id: int) -> Backup: router = self._router_for_user(db, user, router_id) if router.device_type != 'routeros': raise HTTPException(status_code=400, detail='Text export is available only for RouterOS devices') settings = settings_service.get_or_create(db) stamp = datetime.now().strftime('%Y%m%d_%H%M%S') name = f'{router.name}_{router.id}_{stamp}.rsc' file_path = ensure_data_dir() / name try: content = router_service.export(router, settings.global_ssh_key) file_path.write_text(content, encoding='utf-8') backup = Backup(router_id=router.id, file_path=str(file_path), file_name=name, backup_type='export') db.add(backup) db.commit() db.refresh(backup) log_service.add(db, f'Export OK for device {router.name}') notification_service.notify(settings, f'Export {router.name} OK', True) return backup except HTTPException: raise except Exception as exc: notification_service.notify(settings, f'Export {router.name} FAIL: {exc}', False) log_service.add(db, f'Export FAILED for device {router.name}: {exc}') raise HTTPException(status_code=500, detail=str(exc)) from exc def binary_backup(self, db: Session, user: User, router_id: int) -> Backup: router = self._router_for_user(db, user, router_id) settings = settings_service.get_or_create(db) stamp = datetime.now().strftime('%Y%m%d_%H%M%S') base_name = f'{router.name}_{router.id}_{stamp}' extension = '.swb' if router.device_type == 'switchos' else '.backup' name = f'{base_name}{extension}' file_path = ensure_data_dir() / name try: router_service.binary_backup(router, base_name, str(file_path), settings.global_ssh_key, settings) checksum = compute_checksum(str(file_path)) backup = Backup(router_id=router.id, file_path=str(file_path), file_name=name, backup_type='binary', checksum=checksum) db.add(backup) db.commit() db.refresh(backup) log_service.add(db, f'Binary backup OK for {self._device_label(router)}') notification_service.notify(settings, f'Backup {router.name} OK', True) return backup except HTTPException: raise except Exception as exc: notification_service.notify(settings, f'Backup {router.name} FAIL: {exc}', False) log_service.add(db, f'Binary backup FAILED for {self._device_label(router)}: {exc}') raise HTTPException(status_code=500, detail=str(exc)) from exc def upload_backup_to_router(self, db: Session, user: User, router_id: int, backup_id: int): router = self._router_for_user(db, user, router_id) if router.device_type != 'routeros': raise HTTPException(status_code=400, detail='Restore upload is available only for RouterOS devices') backup = self.get_backup_for_user(db, user, backup_id) if backup.backup_type != 'binary': raise HTTPException(status_code=400, detail='Only binary backups can be uploaded') if backup.router and backup.router.device_type != 'routeros': raise HTTPException(status_code=400, detail='SwitchOS backup files cannot be restored over SSH upload') checksum = compute_checksum(backup.file_path) if backup.checksum and checksum != backup.checksum: raise HTTPException(status_code=400, detail='Checksum mismatch') settings = settings_service.get_or_create(db) router_service.upload_backup(router, backup.file_path, settings.global_ssh_key) log_service.add(db, f'Upload backup OK for device {router.name}') def delete_backup(self, db: Session, user: User, backup_id: int, commit: bool = True): backup = self.get_backup_for_user(db, user, backup_id) path = Path(backup.file_path) if path.exists(): path.unlink() db.delete(backup) if commit: db.commit() def diff_backups(self, db: Session, user: User, left_id: int, right_id: int): left = self.get_backup_for_user(db, user, left_id) right = self.get_backup_for_user(db, user, right_id) if left.backup_type != 'export' or right.backup_type != 'export': raise HTTPException(status_code=400, detail='Diff is supported only for export backups') left_lines = Path(left.file_path).read_text(encoding='utf-8', errors='ignore').splitlines() right_lines = Path(right.file_path).read_text(encoding='utf-8', errors='ignore').splitlines() diff_lines = list( difflib.unified_diff(left_lines, right_lines, fromfile=left.file_name, tofile=right.file_name, lineterm='') ) diff_html = difflib.HtmlDiff(wrapcolumn=120).make_file( left_lines, right_lines, fromdesc=left.file_name, todesc=right.file_name, context=True, numlines=2, ) structured_lines, stats = self._build_structured_diff(left_lines, right_lines) return { 'left_backup_id': left_id, 'right_backup_id': right_id, 'left_file_name': left.file_name, 'right_file_name': right.file_name, 'diff_text': '\n'.join(diff_lines), 'diff_html': diff_html, 'stats': stats, 'lines': structured_lines, } def email_backup(self, db: Session, user: User, backup_id: int): backup = self.get_backup_for_user(db, user, backup_id) settings = settings_service.get_or_create(db) platform_name = 'SwitchOS' if backup.router and backup.router.device_type == 'switchos' else 'RouterOS' noun = 'Export' if backup.backup_type == 'export' else 'Backup' subject = f'{platform_name} {noun}: {backup.file_name}' body = f'Sending {backup.file_name} from device {backup.router.name}.' notification_service.send_email(settings, subject, body, backup.file_path) log_service.add(db, f'Email sent for backup {backup.file_name}') def export_all(self, db: Session, user: User): routers = db.query(Router).filter(Router.owner_id == user.id).all() result = [] for router in routers: if (router.device_type or 'routeros').lower() != 'routeros': result.append({ 'router': router.name, 'status': 'skipped', 'message': 'Text export is available only for RouterOS devices', }) continue try: backup = self.export_router(db, user, router.id) result.append({'router': router.name, 'status': 'ok', 'backup_id': backup.id}) except Exception as exc: result.append({'router': router.name, 'status': 'error', 'message': str(exc)}) return result def binary_all(self, db: Session, user: User): routers = db.query(Router).filter(Router.owner_id == user.id).all() result = [] for router in routers: try: backup = self.binary_backup(db, user, router.id) result.append({'router': router.name, 'status': 'ok', 'backup_id': backup.id}) except Exception as exc: result.append({'router': router.name, 'status': 'error', 'message': str(exc)}) return result def cleanup_old_backups(self, db: Session): settings = settings_service.get_or_create(db) cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=settings.backup_retention_days) old_backups = db.query(Backup).filter(Backup.created_at < cutoff).all() deleted_count = 0 for backup in old_backups: path = Path(backup.file_path) if path.exists(): path.unlink() db.delete(backup) deleted_count += 1 db.commit() log_service.add(db, f'Retention cleanup removed {deleted_count} backups older than {settings.backup_retention_days} days') return deleted_count backup_service = BackupService()