Jul 14 - Using CDN - Phase 1
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
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/<subfolder>/<file>`` —
|
||||
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 == <root>/app/static/uploads
|
||||
return current_app.config['UPLOAD_FOLDER']
|
||||
|
||||
def _static_folder(self):
|
||||
# <root>/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)
|
||||
Reference in New Issue
Block a user