| """ |
| S3 storage service for backup operations. |
| |
| Supports any S3-compatible provider: AWS, MinIO, DigitalOcean Spaces, Backblaze B2, etc. |
| """ |
|
|
| import ipaddress |
| import logging |
| import socket |
| from typing import Tuple |
| from urllib.parse import urlparse |
|
|
| from core.models import GlobalSettings |
| from core.services.encryption_service import EncryptionService, EncryptionError |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| def is_safe_endpoint_url(url: str) -> tuple[bool, str]: |
| """ |
| Validate that an S3 endpoint URL doesn't point to internal/private resources. |
| |
| Blocks: |
| - Private IP ranges (10.x, 172.16-31.x, 192.168.x) |
| - Loopback addresses (127.x, localhost) |
| - Link-local addresses (169.254.x - AWS metadata endpoint) |
| - Internal hostnames |
| |
| Args: |
| url: The endpoint URL to validate |
| |
| Returns: |
| Tuple of (is_safe: bool, error_message: str) |
| """ |
| if not url: |
| return True, "" |
|
|
| try: |
| parsed = urlparse(url) |
| hostname = parsed.hostname |
|
|
| if not hostname: |
| return False, "Invalid URL: no hostname found" |
|
|
| |
| if hostname.lower() in ("localhost", "localhost.localdomain"): |
| return False, "Internal endpoints are not allowed (localhost)" |
|
|
| |
| try: |
| |
| addr_info = socket.getaddrinfo(hostname, None) |
| for _, _, _, _, sockaddr in addr_info: |
| ip_str = sockaddr[0] |
| ip = ipaddress.ip_address(ip_str) |
|
|
| |
| if ip.is_private: |
| return False, f"Private IP addresses are not allowed ({ip_str})" |
| if ip.is_loopback: |
| return False, f"Loopback addresses are not allowed ({ip_str})" |
| if ip.is_link_local: |
| return False, f"Link-local addresses are not allowed ({ip_str})" |
| if ip.is_reserved: |
| return False, f"Reserved addresses are not allowed ({ip_str})" |
|
|
| except socket.gaierror: |
| |
| |
| logger.warning(f"S3 endpoint hostname does not resolve: {hostname}") |
|
|
| return True, "" |
|
|
| except Exception as e: |
| return False, f"Invalid endpoint URL: {e}" |
|
|
|
|
| class S3ServiceError(Exception): |
| """Raised when S3 operations fail.""" |
|
|
| pass |
|
|
|
|
| class S3Service: |
| """ |
| Provides S3 storage operations for backup functionality. |
| |
| Supports any S3-compatible provider: AWS, MinIO, DigitalOcean Spaces, etc. |
| """ |
|
|
| @classmethod |
| def get_client(cls): |
| """ |
| Create and return a configured boto3 S3 client. |
| |
| Returns: |
| boto3 S3 client configured with current settings |
| |
| Raises: |
| S3ServiceError: If S3 is not configured or credentials invalid |
| """ |
| try: |
| import boto3 |
| from botocore.config import Config |
| except ImportError: |
| raise S3ServiceError( |
| "boto3 is not installed. Install it with: pip install boto3" |
| ) |
|
|
| settings = GlobalSettings.get_settings() |
|
|
| if not settings.s3_bucket_name: |
| raise S3ServiceError("S3 bucket name is not configured") |
|
|
| if not settings.s3_access_key_encrypted or not settings.s3_secret_key_encrypted: |
| raise S3ServiceError("S3 credentials are not configured") |
|
|
| |
| try: |
| access_key = EncryptionService.decrypt(settings.s3_access_key_encrypted) |
| secret_key = EncryptionService.decrypt(settings.s3_secret_key_encrypted) |
| except EncryptionError as e: |
| raise S3ServiceError(f"Failed to decrypt S3 credentials: {e}") |
|
|
| |
| config = Config( |
| signature_version="s3v4", |
| s3={"addressing_style": "path" if settings.s3_path_style else "auto"}, |
| ) |
|
|
| client_kwargs = { |
| "service_name": "s3", |
| "aws_access_key_id": access_key, |
| "aws_secret_access_key": secret_key, |
| "config": config, |
| "use_ssl": settings.s3_use_ssl, |
| } |
|
|
| if settings.s3_endpoint_url: |
| |
| is_safe, error_msg = is_safe_endpoint_url(settings.s3_endpoint_url) |
| if not is_safe: |
| raise S3ServiceError(f"Invalid S3 endpoint: {error_msg}") |
| client_kwargs["endpoint_url"] = settings.s3_endpoint_url |
|
|
| if settings.s3_region: |
| client_kwargs["region_name"] = settings.s3_region |
|
|
| return boto3.client(**client_kwargs) |
|
|
| @classmethod |
| def test_connection(cls) -> Tuple[bool, str]: |
| """ |
| Test the S3 connection by attempting to access the bucket. |
| |
| Returns: |
| Tuple of (success: bool, message: str) |
| """ |
| from django.utils import timezone |
|
|
| settings = GlobalSettings.get_settings() |
|
|
| try: |
| client = cls.get_client() |
|
|
| |
| client.head_bucket(Bucket=settings.s3_bucket_name) |
|
|
| |
| settings.s3_last_tested_at = timezone.now() |
| settings.save(update_fields=["s3_last_tested_at"]) |
|
|
| return True, f"Successfully connected to bucket '{settings.s3_bucket_name}'" |
|
|
| except S3ServiceError as e: |
| return False, str(e) |
| except Exception as e: |
| error_msg = str(e) |
| |
| if "NoSuchBucket" in error_msg: |
| return False, f"Bucket '{settings.s3_bucket_name}' does not exist" |
| elif "AccessDenied" in error_msg or "403" in error_msg: |
| return ( |
| False, |
| "Access denied. Check your credentials and bucket permissions", |
| ) |
| elif "InvalidAccessKeyId" in error_msg: |
| return False, "Invalid access key ID" |
| elif "SignatureDoesNotMatch" in error_msg: |
| return False, "Invalid secret access key" |
| elif "EndpointConnectionError" in error_msg or "ConnectTimeoutError" in error_msg: |
| return ( |
| False, |
| "Cannot connect to endpoint. Check URL and network connectivity", |
| ) |
| elif "InvalidEndpoint" in error_msg: |
| return False, "Invalid endpoint URL format" |
| else: |
| logger.exception("S3 connection test failed") |
| return False, f"Connection failed: {error_msg}" |
|
|
| @classmethod |
| def test_connection_with_credentials( |
| cls, |
| bucket_name: str, |
| access_key: str, |
| secret_key: str, |
| endpoint_url: str = "", |
| region: str = "us-east-1", |
| use_ssl: bool = True, |
| path_style: bool = False, |
| ) -> Tuple[bool, str]: |
| """ |
| Test S3 connection with provided credentials (without saving). |
| |
| This is used to validate credentials before saving settings. |
| |
| Args: |
| bucket_name: S3 bucket name |
| access_key: AWS access key ID |
| secret_key: AWS secret access key |
| endpoint_url: Custom endpoint URL (empty for AWS) |
| region: AWS region |
| use_ssl: Whether to use SSL |
| path_style: Use path-style addressing |
| |
| Returns: |
| Tuple of (success: bool, message: str) |
| """ |
| |
| if not bucket_name: |
| return False, "Bucket name is required" |
| if not access_key: |
| return False, "Access key is required" |
| if not secret_key: |
| return False, "Secret key is required" |
|
|
| try: |
| import boto3 |
| from botocore.config import Config |
| except ImportError: |
| return False, "boto3 is not installed. Install it with: pip install boto3" |
|
|
| try: |
| |
| config = Config( |
| signature_version="s3v4", |
| s3={"addressing_style": "path" if path_style else "auto"}, |
| ) |
|
|
| client_kwargs = { |
| "service_name": "s3", |
| "aws_access_key_id": access_key, |
| "aws_secret_access_key": secret_key, |
| "config": config, |
| "use_ssl": use_ssl, |
| } |
|
|
| if endpoint_url: |
| |
| is_safe, error_msg = is_safe_endpoint_url(endpoint_url) |
| if not is_safe: |
| return False, f"Invalid S3 endpoint: {error_msg}" |
| client_kwargs["endpoint_url"] = endpoint_url |
|
|
| if region: |
| client_kwargs["region_name"] = region |
|
|
| client = boto3.client(**client_kwargs) |
|
|
| |
| client.head_bucket(Bucket=bucket_name) |
|
|
| return True, f"Successfully connected to bucket '{bucket_name}'" |
|
|
| except Exception as e: |
| error_msg = str(e) |
| |
| if "NoSuchBucket" in error_msg: |
| return False, f"Bucket '{bucket_name}' does not exist" |
| elif "AccessDenied" in error_msg or "403" in error_msg: |
| return ( |
| False, |
| "Access denied. Check your credentials and bucket permissions", |
| ) |
| elif "InvalidAccessKeyId" in error_msg: |
| return False, "Invalid access key ID" |
| elif "SignatureDoesNotMatch" in error_msg: |
| return False, "Invalid secret access key" |
| elif "EndpointConnectionError" in error_msg or "ConnectTimeoutError" in error_msg: |
| return ( |
| False, |
| "Cannot connect to endpoint. Check URL and network connectivity", |
| ) |
| elif "InvalidEndpoint" in error_msg: |
| return False, "Invalid endpoint URL format" |
| else: |
| logger.exception("S3 connection test failed") |
| return False, f"Connection failed: {error_msg}" |
|
|
| @classmethod |
| def is_configured(cls) -> bool: |
| """Check if S3 is properly configured (has required fields).""" |
| settings = GlobalSettings.get_settings() |
| return bool( |
| settings.s3_bucket_name |
| and settings.s3_access_key_encrypted |
| and settings.s3_secret_key_encrypted |
| ) |
|
|
| @classmethod |
| def get_status(cls) -> dict: |
| """ |
| Get S3 configuration status for UI display. |
| |
| Returns: |
| Dict with status information |
| """ |
| settings = GlobalSettings.get_settings() |
|
|
| return { |
| "enabled": settings.s3_enabled, |
| "configured": cls.is_configured(), |
| "bucket": settings.s3_bucket_name or None, |
| "endpoint": settings.s3_endpoint_url or "AWS S3 (default)", |
| "region": settings.s3_region or "us-east-1", |
| "use_ssl": settings.s3_use_ssl, |
| "path_style": settings.s3_path_style, |
| "last_tested": settings.s3_last_tested_at, |
| } |
|
|
| @classmethod |
| def upload_file(cls, file_bytes: bytes, key: str) -> dict: |
| """ |
| Upload a file to S3. |
| |
| Args: |
| file_bytes: File content as bytes |
| key: S3 object key (path) |
| |
| Returns: |
| dict: Upload result with 'success', 'key', 'size', optional 'error' |
| """ |
| settings = GlobalSettings.get_settings() |
|
|
| try: |
| client = cls.get_client() |
| client.put_object( |
| Bucket=settings.s3_bucket_name, |
| Key=key, |
| Body=file_bytes, |
| ContentType="application/gzip", |
| ) |
|
|
| return { |
| "success": True, |
| "key": key, |
| "size": len(file_bytes), |
| } |
| except S3ServiceError as e: |
| logger.error(f"S3 upload failed for key {key}: {e}") |
| return { |
| "success": False, |
| "key": key, |
| "error": str(e), |
| } |
| except Exception as e: |
| logger.exception(f"S3 upload failed for key {key}") |
| return { |
| "success": False, |
| "key": key, |
| "error": str(e), |
| } |
|
|
| @classmethod |
| def list_files(cls, prefix: str = "") -> list[dict]: |
| """ |
| List files in S3 bucket with optional prefix. |
| |
| Args: |
| prefix: Key prefix to filter results |
| |
| Returns: |
| list: List of dicts with 'key', 'size', 'last_modified' |
| """ |
| settings = GlobalSettings.get_settings() |
|
|
| try: |
| client = cls.get_client() |
| response = client.list_objects_v2( |
| Bucket=settings.s3_bucket_name, |
| Prefix=prefix, |
| ) |
|
|
| files = [] |
| for obj in response.get("Contents", []): |
| files.append({ |
| "key": obj["Key"], |
| "size": obj["Size"], |
| "last_modified": obj["LastModified"], |
| }) |
|
|
| return files |
| except S3ServiceError as e: |
| logger.error(f"S3 list failed for prefix {prefix}: {e}") |
| return [] |
| except Exception as e: |
| logger.exception(f"S3 list failed for prefix {prefix}") |
| return [] |
|
|
| @classmethod |
| def delete_file(cls, key: str) -> bool: |
| """ |
| Delete a file from S3. |
| |
| Args: |
| key: S3 object key to delete |
| |
| Returns: |
| bool: True if deleted successfully |
| """ |
| settings = GlobalSettings.get_settings() |
|
|
| try: |
| client = cls.get_client() |
| client.delete_object( |
| Bucket=settings.s3_bucket_name, |
| Key=key, |
| ) |
| return True |
| except Exception as e: |
| logger.exception(f"S3 delete failed for key {key}") |
| return False |
|
|
| @classmethod |
| def delete_files(cls, keys: list[str]) -> int: |
| """ |
| Delete multiple files from S3. |
| |
| Args: |
| keys: List of S3 object keys to delete |
| |
| Returns: |
| int: Number of files deleted |
| """ |
| settings = GlobalSettings.get_settings() |
|
|
| if not keys: |
| return 0 |
|
|
| try: |
| client = cls.get_client() |
| response = client.delete_objects( |
| Bucket=settings.s3_bucket_name, |
| Delete={ |
| "Objects": [{"Key": key} for key in keys], |
| }, |
| ) |
| return len(response.get("Deleted", [])) |
| except Exception as e: |
| logger.exception("S3 bulk delete failed") |
| return 0 |
|
|
| @classmethod |
| def generate_backup_key(cls, timestamp=None) -> str: |
| """ |
| Generate a unique S3 key for a backup file. |
| |
| Args: |
| timestamp: Optional datetime, defaults to now |
| |
| Returns: |
| str: S3 key like "pyrunner-backups/backup_20240315_143022.json.gz" |
| """ |
| from django.utils import timezone |
|
|
| settings = GlobalSettings.get_settings() |
|
|
| if timestamp is None: |
| timestamp = timezone.now() |
|
|
| prefix = settings.s3_backup_prefix.rstrip("/") |
| filename = f"backup_{timestamp.strftime('%Y%m%d_%H%M%S')}.json.gz" |
|
|
| return f"{prefix}/{filename}" if prefix else filename |
|
|