""" app/utils/storage.py --------------------- Storage abstraction for uploaded photo files (R2 object-storage migration). One interface, backend selected by config ``STORAGE_BACKEND``: - ``local`` (default): files under ``app/static/uploads``, served via ``url_for('static', ...)``. **Byte-for-byte identical** to the behavior before this abstraction existed — Phase 1 is a no-op. - ``s3`` (added in Phase 2): Cloudflare R2 / any S3-compatible store. The stored **key** is always the relative path ``uploads//`` — the exact string persisted in the DB (`Issue.photo_path`, `result_photos[]`, `mobile_photo_paths[]`, `Inspection.form_data` image values, `InspectionResult.photo_path`). It never changes between backends, so there is **no schema migration**. Public module-level API (delegates to the active backend): save(file_obj, subfolder) -> key # write an uploaded file, return its key media_url(key) -> str # browser/API URL for a key ('' if falsy) read(key) -> bytes # raw bytes (e.g. PDF export image embed) exists(key) -> bool delete(key) -> None # best-effort file removal abs_local_path(key) -> str # physical path under static/ (local backend) NOTE: extension / magic-byte validation stays in the CALLER (e.g. `_save_photo`, `api/photos.upload_photo`) — this module only moves bytes and builds URLs. """ import os import uuid import logging from flask import current_app, url_for logger = logging.getLogger(__name__) def _ext_of(filename): """Lower-case extension of a filename, defaulting to 'jpg' when absent.""" name = filename or '' return name.rsplit('.', 1)[-1].lower() if '.' in name else 'jpg' # ── Local filesystem backend (current behavior) ─────────────────────────────── class LocalBackend: """Files under ``app/static/uploads``, served by Flask/Nginx static route.""" name = 'local' def _uploads_root(self): # config UPLOAD_FOLDER == /app/static/uploads return current_app.config['UPLOAD_FOLDER'] def _static_folder(self): # /app/static — key 'uploads/...' resolves under here return os.path.join(current_app.root_path, 'static') def save(self, file_obj, subfolder): """Write ``file_obj`` under ``subfolder``; return the ``uploads/...`` key. Replicates the previous inline logic in `_save_photo` / `upload_photo` exactly: random uuid filename, `os.makedirs(exist_ok=True)`, same key. """ ext = _ext_of(file_obj.filename) filename = f'{uuid.uuid4().hex}.{ext}' dest_dir = os.path.join(self._uploads_root(), subfolder) os.makedirs(dest_dir, exist_ok=True) file_obj.save(os.path.join(dest_dir, filename)) return f'uploads/{subfolder}/{filename}' def abs_local_path(self, key): return os.path.normpath(os.path.join(self._static_folder(), key)) def media_url(self, key): if not key: return '' return url_for('static', filename=key) def read(self, key): with open(self.abs_local_path(key), 'rb') as fh: return fh.read() def exists(self, key): return bool(key) and os.path.isfile(self.abs_local_path(key)) def delete(self, key): if not key: return try: path = self.abs_local_path(key) if os.path.isfile(path): os.remove(path) except OSError: pass # S3Backend (Cloudflare R2) is added in Phase 2 and registered below. _BACKENDS = { 'local': LocalBackend, } def get_backend(): """Return the active storage backend (one cached instance per app).""" name = (current_app.config.get('STORAGE_BACKEND') or 'local').lower() cls = _BACKENDS.get(name) if cls is None: logger.warning('Unknown STORAGE_BACKEND=%r — falling back to local', name) cls = LocalBackend cache_key = f'_storage_backend_{cls.name}' inst = current_app.extensions.get(cache_key) if inst is None: inst = cls() current_app.extensions[cache_key] = inst return inst # ── Module-level convenience delegates ──────────────────────────────────────── def save(file_obj, subfolder): return get_backend().save(file_obj, subfolder) def media_url(key): return get_backend().media_url(key) def read(key): return get_backend().read(key) def exists(key): return get_backend().exists(key) def delete(key): return get_backend().delete(key) def abs_local_path(key): return get_backend().abs_local_path(key)