Jul 14 - Using CDN - Phase 2a

This commit is contained in:
2026-07-14 12:34:28 -04:00
parent bab564c23e
commit 9edece2726
6 changed files with 140 additions and 24 deletions
+5 -8
View File
@@ -1335,15 +1335,12 @@ def delete(inspection_id):
db.session.delete(inspection)
db.session.commit()
# Remove orphaned photo files from the active storage backend — best-effort.
# (Also fixes the previous double-'static' path that normalized to
# app/static/static/... and never actually deleted anything.)
from app.utils import storage
for rel_path in photo_paths:
abs_path = os.path.normpath(
os.path.join(current_app.config['UPLOAD_FOLDER'], '..', 'static', rel_path)
)
try:
if os.path.isfile(abs_path):
os.remove(abs_path)
except OSError:
pass
storage.delete(rel_path)
current_app.logger.info(
'INSPECTION DELETED | id=%s | facility="%s" | template="%s" | '
+3 -8
View File
@@ -1009,15 +1009,10 @@ def delete(issue_id):
db.session.delete(issue)
db.session.commit()
# Remove orphaned photo files — best-effort, never block on failure
static_folder = current_app.root_path
# Remove orphaned photo files from the active storage backend — best-effort.
from app.utils import storage
for rel_path in photo_paths:
abs_path = os.path.normpath(os.path.join(static_folder, 'static', rel_path))
try:
if os.path.isfile(abs_path):
os.remove(abs_path)
except OSError:
pass
storage.delete(rel_path)
current_app.logger.info(
'ISSUE DELETED | id=%s | severity=%s | area=%s | facility=%s | deleted_by=%s',
+106 -2
View File
@@ -7,7 +7,9 @@ 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.
- ``s3``: Cloudflare R2 / any S3-compatible store. Private bucket; browser/API
URLs are short-lived presigned GETs. Requires ``boto3`` and the ``R2_*``
config keys. boto3 is imported lazily, so a ``local`` deploy needs neither.
The stored **key** is always the relative path ``uploads/<subfolder>/<file>`` —
the exact string persisted in the DB (`Issue.photo_path`, `result_photos[]`,
@@ -97,10 +99,112 @@ class LocalBackend:
pass
# S3Backend (Cloudflare R2) is added in Phase 2 and registered below.
# ── Cloudflare R2 / S3-compatible backend ─────────────────────────────────────
_CONTENT_TYPES = {
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
'png': 'image/png', 'gif': 'image/gif',
}
class S3Backend:
"""
Cloudflare R2 (or any S3-compatible store). Objects are private; browser/API
URLs are short-lived presigned GETs. The object key == the DB path string.
boto3 is imported lazily so a 'local' deployment doesn't require it installed.
"""
name = 's3'
def __init__(self):
import boto3 # lazy — only when s3 is active
from botocore.config import Config as _BotoConfig
cfg = current_app.config
missing = [k for k in ('R2_ENDPOINT_URL', 'R2_ACCESS_KEY_ID',
'R2_SECRET_ACCESS_KEY', 'R2_BUCKET') if not cfg.get(k)]
if missing:
raise RuntimeError(f'STORAGE_BACKEND=s3 but missing config: {", ".join(missing)}')
self.bucket = cfg['R2_BUCKET']
self.ttl = int(cfg.get('R2_PRESIGN_TTL', 86400))
self.fallback = bool(cfg.get('R2_MEDIA_FALLBACK', False))
self._client = boto3.client(
's3',
endpoint_url = cfg['R2_ENDPOINT_URL'],
aws_access_key_id = cfg['R2_ACCESS_KEY_ID'],
aws_secret_access_key = cfg['R2_SECRET_ACCESS_KEY'],
region_name = 'auto',
config = _BotoConfig(signature_version='s3v4'),
)
# A LocalBackend for transition fallback (read/media_url when object absent).
self._local = LocalBackend()
def _content_type(self, key):
return _CONTENT_TYPES.get(_ext_of(key), 'application/octet-stream')
def save(self, file_obj, subfolder):
ext = _ext_of(file_obj.filename)
key = f'uploads/{subfolder}/{uuid.uuid4().hex}.{ext}'
file_obj.stream.seek(0)
body = file_obj.stream.read()
self._client.put_object(
Bucket=self.bucket, Key=key, Body=body,
ContentType=_CONTENT_TYPES.get(ext, 'application/octet-stream'),
)
return key
def abs_local_path(self, key):
# No local path for an S3 object; expose the local mirror path (may or may
# not exist) so transition code / callers can still reference it.
return self._local.abs_local_path(key)
def media_url(self, key):
if not key:
return ''
if self.fallback and not self._head(key):
# Object not in R2 yet — serve the local copy during transition.
if self._local.exists(key):
return self._local.media_url(key)
return self._client.generate_presigned_url(
'get_object',
Params={'Bucket': self.bucket, 'Key': key},
ExpiresIn=self.ttl,
)
def read(self, key):
try:
resp = self._client.get_object(Bucket=self.bucket, Key=key)
return resp['Body'].read()
except Exception:
# Transition safety: fall back to a local copy if present.
if self._local.exists(key):
return self._local.read(key)
raise
def _head(self, key):
try:
self._client.head_object(Bucket=self.bucket, Key=key)
return True
except Exception:
return False
def exists(self, key):
return bool(key) and self._head(key)
def delete(self, key):
if not key:
return
try:
self._client.delete_object(Bucket=self.bucket, Key=key)
except Exception as e:
logger.warning('S3 delete failed for %s: %s', key, e)
_BACKENDS = {
'local': LocalBackend,
's3': S3Backend,
}