import os import math import hashlib import base64 from io import BytesIO from PIL import Image from django.db import models from django.core.files.base import ContentFile import logging logger = logging.getLogger(__name__) def _get_fernet(): """ Returns a Fernet (or MultiFernet) instance for encryption/decryption. Settings: FIELD_ENCRYPTION_KEY — single key (str or bytes), or a list of keys for rotation. First key in a list is used for new encrypts; all are tried for decrypts, so old values remain readable after a key change. Falls back to a SECRET_KEY-derived key when not set (development only). Key rotation workflow: 1. Add new key as the FIRST item in FIELD_ENCRYPTION_KEY list. 2. Keep old key(s) as remaining items — old values still decrypt fine. 3. Optionally run a migration command to re-encrypt all rows with the new key, then remove old keys from the list. """ from cryptography.fernet import Fernet, MultiFernet from django.conf import settings raw_setting = getattr(settings, 'FIELD_ENCRYPTION_KEY', None) if isinstance(raw_setting, (str, bytes)): # Single key key = raw_setting.encode() if isinstance(raw_setting, str) else raw_setting return Fernet(key) # List of keys — first is active, rest are for decrypting old values fernet_keys = [ Fernet(k.encode() if isinstance(k, str) else k) for k in raw_setting ] return MultiFernet(fernet_keys) class WebPImageField(models.ImageField): """Converts uploaded images to WebP on save. Drop-in replacement for ImageField.""" def pre_save(self, model_instance, add): uploaded_file = getattr(model_instance, self.attname) if uploaded_file and not uploaded_file._committed: self._convert_to_webp(uploaded_file) return super().pre_save(model_instance, add) def _convert_to_webp(self, uploaded_file): try: uploaded_file.open() image = Image.open(uploaded_file) if image.format == 'WEBP': image.close() return # Preserve transparency for palette/alpha modes if image.mode == 'P': image = image.convert('RGBA') elif image.mode not in ('RGBA', 'LA'): image = image.convert('RGB') webp_buffer = BytesIO() image.save(webp_buffer, format='WEBP', quality=100, optimize=True) image.close() webp_filename = os.path.splitext(uploaded_file.name)[0] + '.webp' uploaded_file.save(webp_filename, ContentFile(webp_buffer.getvalue()), save=False) except Exception as error: logger.error(f"WebP conversion failed: {error}") class EncryptedCharField(models.CharField): """ Transparently encrypts values at rest using Fernet (AES-128-CBC + HMAC-SHA256). Drop-in replacement for CharField — max_length is the plaintext limit. Set FIELD_ENCRYPTION_KEY in settings to a URL-safe base64 32-byte key (generate: from cryptography.fernet import Fernet; Fernet.generate_key()). """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # DB column must fit the Fernet token: plaintext + 73 bytes overhead, base64-encoded plaintext_max_length = self.max_length or 200 self._db_column_max_length = math.ceil((plaintext_max_length + 73) / 3) * 4 def db_type(self, connection): # Silently use the larger column size; migrations still show plaintext max_length return connection.data_types['CharField'] % {'max_length': self._db_column_max_length} def from_db_value(self, value, expression, connection): if not value: return value try: return _get_fernet().decrypt(value.encode()).decode() except Exception: logger.warning("EncryptedCharField: decryption failed, returning raw value.") return value def get_prep_value(self, value): if value is None or value == '': return super().get_prep_value(value) return _get_fernet().encrypt(value.encode()).decode() def to_python(self, value): if value is None: return value return str(value)