Jul 14 - Using CDN - Phase 2a
This commit is contained in:
+106
-2
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user