Jul 16 - Fill the gaps between Single-tenant mode and Multi-tenant mode - MT2

This commit is contained in:
2026-07-16 16:59:17 -04:00
parent 8ff38578ad
commit a60afc6c41
10 changed files with 478 additions and 44 deletions
+18 -1
View File
@@ -122,6 +122,11 @@ def create_app(config_name='default'):
app.jinja_env.globals['sla_hours_remaining'] = sla_hours_remaining app.jinja_env.globals['sla_hours_remaining'] = sla_hours_remaining
app.jinja_env.globals['SLA_HOURS'] = SLA_HOURS app.jinja_env.globals['SLA_HOURS'] = SLA_HOURS
# Photo URL resolver — routes through the active storage backend so templates
# work unchanged when the backend flips from local to R2 (see utils/storage.py).
from app.utils import storage as _storage
app.jinja_env.globals['media_url'] = _storage.media_url
# ── Inject unread notification count into every template context ────── # ── Inject unread notification count into every template context ──────
# This powers the red badge on the navbar bell icon without requiring # This powers the red badge on the navbar bell icon without requiring
# individual routes to pass the count manually. # individual routes to pass the count manually.
@@ -275,6 +280,18 @@ def create_app(config_name='default'):
# ── Security response headers ───────────────────────────────────────── # ── Security response headers ─────────────────────────────────────────
# Applied to every response. Blocks clickjacking, MIME sniffing, and # Applied to every response. Blocks clickjacking, MIME sniffing, and
# obvious XSS vectors without breaking Bootstrap CDN / Google Fonts. # obvious XSS vectors without breaking Bootstrap CDN / Google Fonts.
# Allow R2 presigned photo URLs in the CSP img-src when the s3 storage
# backend is configured. Derived from R2_ENDPOINT_URL (the presigned URL
# host is the same R2 account endpoint), so nothing is hardcoded and the
# local backend is unaffected.
_r2_img_src = ''
_r2_endpoint = app.config.get('R2_ENDPOINT_URL')
if _r2_endpoint:
from urllib.parse import urlparse
_r2_host = urlparse(_r2_endpoint).netloc
if _r2_host:
_r2_img_src = f' https://{_r2_host}'
@app.after_request @app.after_request
def set_security_headers(response): def set_security_headers(response):
from flask import request as _request from flask import request as _request
@@ -287,7 +304,7 @@ def create_app(config_name='default'):
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; " "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; "
"font-src 'self' data: https://fonts.gstatic.com https://cdn.jsdelivr.net; " "font-src 'self' data: https://fonts.gstatic.com https://cdn.jsdelivr.net; "
"img-src 'self' data: blob: https://maps.gstatic.com https://maps.googleapis.com; " f"img-src 'self' data: blob: https://maps.gstatic.com https://maps.googleapis.com{_r2_img_src}; "
"connect-src 'self' https://cdn.jsdelivr.net; " "connect-src 'self' https://cdn.jsdelivr.net; "
"frame-src https://maps.google.com https://www.google.com; " "frame-src https://maps.google.com https://www.google.com; "
# Hardening directives that don't affect existing inline scripts/styles: # Hardening directives that don't affect existing inline scripts/styles:
+4 -10
View File
@@ -85,16 +85,10 @@ def upload_photo():
else: else:
subfolder = 'inspection_photos' subfolder = 'inspection_photos'
ext = file_obj.filename.rsplit('.', 1)[-1].lower() # Write via the active storage backend (local disk or R2). Key format
filename = f'{uuid.uuid4().hex}.{ext}' # 'uploads/<subfolder>/<uuid>.<ext>' is unchanged across backends.
from app.utils import storage
dest_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], subfolder) server_path = storage.save(file_obj, subfolder)
os.makedirs(dest_dir, exist_ok=True)
dest_path = os.path.join(dest_dir, filename)
file_obj.save(dest_path)
server_path = f'uploads/{subfolder}/{filename}'
logger.info('API PHOTOS | uploaded | entity_type=%s | path=%s | user=%s', logger.info('API PHOTOS | uploaded | entity_type=%s | path=%s | user=%s',
entity_type, server_path, user.username) entity_type, server_path, user.username)
+9 -13
View File
@@ -59,11 +59,10 @@ def _save_photo(file_obj, subfolder='inspection_photos'):
file_obj.seek(0) file_obj.seek(0)
if not any(header.startswith(m) for m in _IMAGE_MAGIC): if not any(header.startswith(m) for m in _IMAGE_MAGIC):
return None return None
filename = f"{uuid.uuid4().hex}.{ext}" # Write via the active storage backend (local disk or R2). Key format
dest_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], subfolder) # 'uploads/<subfolder>/<uuid>.<ext>' is unchanged across backends.
os.makedirs(dest_dir, exist_ok=True) from app.utils import storage
file_obj.save(os.path.join(dest_dir, filename)) return storage.save(file_obj, subfolder)
return f"uploads/{subfolder}/{filename}"
def _collect_form_responses(form_fields, existing_responses=None): def _collect_form_responses(form_fields, existing_responses=None):
@@ -1323,15 +1322,12 @@ def delete(inspection_id):
db.session.delete(inspection) db.session.delete(inspection)
db.session.commit() 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: for rel_path in photo_paths:
abs_path = os.path.normpath( storage.delete(rel_path)
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
current_app.logger.info( current_app.logger.info(
'INSPECTION DELETED | id=%s | facility="%s" | template="%s" | ' 'INSPECTION DELETED | id=%s | facility="%s" | template="%s" | '
+3 -8
View File
@@ -1008,15 +1008,10 @@ def delete(issue_id):
db.session.delete(issue) db.session.delete(issue)
db.session.commit() db.session.commit()
# Remove orphaned photo files — best-effort, never block on failure # Remove orphaned photo files from the active storage backend — best-effort.
static_folder = current_app.root_path from app.utils import storage
for rel_path in photo_paths: for rel_path in photo_paths:
abs_path = os.path.normpath(os.path.join(static_folder, 'static', rel_path)) storage.delete(rel_path)
try:
if os.path.isfile(abs_path):
os.remove(abs_path)
except OSError:
pass
current_app.logger.info( current_app.logger.info(
'ISSUE DELETED | id=%s | severity=%s | area=%s | facility=%s | deleted_by=%s', 'ISSUE DELETED | id=%s | severity=%s | area=%s | facility=%s | deleted_by=%s',
+1 -1
View File
@@ -439,7 +439,7 @@
</label> </label>
{# Thumbnail shown after AJAX upload or when a saved path exists #} {# Thumbnail shown after AJAX upload or when a saved path exists #}
{% if saved %} {% if saved %}
<img src="{{ url_for('static', filename=saved) }}" <img src="{{ media_url(saved) }}"
id="thumb_{{ fid }}" id="thumb_{{ fid }}"
alt="Photo" alt="Photo"
style="max-height:60px;max-width:100%;border-radius:4px;margin-top:.3rem;object-fit:cover;"> style="max-height:60px;max-width:100%;border-radius:4px;margin-top:.3rem;object-fit:cover;">
+1 -1
View File
@@ -688,7 +688,7 @@
<div style="display:flex;align-items:center;flex:1;min-height:0;"> <div style="display:flex;align-items:center;flex:1;min-height:0;">
{% if val %} {% if val %}
<button type="button" class="btn-view-media" <button type="button" class="btn-view-media"
onclick="openMedia('{{ url_for('static', filename=val) }}','{{ field.label | e }}')"> onclick="openMedia('{{ media_url(val) }}','{{ field.label | e }}')">
<i class="bi bi-image"></i> View Photo <i class="bi bi-image"></i> View Photo
</button> </button>
{% else %} {% else %}
+6 -6
View File
@@ -127,14 +127,14 @@
<h6>Photo Evidence</h6> <h6>Photo Evidence</h6>
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
{% if issue.photo_path %} {% if issue.photo_path %}
<a href="{{ url_for('static', filename=issue.photo_path) }}" target="_blank"> <a href="{{ media_url(issue.photo_path) }}" target="_blank">
<img src="{{ url_for('static', filename=issue.photo_path) }}" <img src="{{ media_url(issue.photo_path) }}"
class="img-fluid rounded" style="max-height:300px; max-width:100%;"> class="img-fluid rounded" style="max-height:300px; max-width:100%;">
</a> </a>
{% endif %} {% endif %}
{% for photo in (issue.mobile_photo_paths or []) %} {% for photo in (issue.mobile_photo_paths or []) %}
<a href="{{ url_for('static', filename=photo) }}" target="_blank"> <a href="{{ media_url(photo) }}" target="_blank">
<img src="{{ url_for('static', filename=photo) }}" <img src="{{ media_url(photo) }}"
class="rounded border" style="max-height:300px; max-width:100%; object-fit:cover;"> class="rounded border" style="max-height:300px; max-width:100%; object-fit:cover;">
</a> </a>
{% endfor %} {% endfor %}
@@ -150,8 +150,8 @@
{% if issue.result_photos %} {% if issue.result_photos %}
<div class="d-flex flex-wrap gap-2 mt-2"> <div class="d-flex flex-wrap gap-2 mt-2">
{% for photo in issue.result_photos %} {% for photo in issue.result_photos %}
<a href="{{ url_for('static', filename=photo) }}" target="_blank"> <a href="{{ media_url(photo) }}" target="_blank">
<img src="{{ url_for('static', filename=photo) }}" <img src="{{ media_url(photo) }}"
class="rounded border" style="max-height:120px; max-width:160px; object-fit:cover;" class="rounded border" style="max-height:120px; max-width:160px; object-fit:cover;"
alt="Result photo"> alt="Result photo">
</a> </a>
+43
View File
@@ -657,6 +657,27 @@ def _notes_section(inspection):
# ── Public entry point ──────────────────────────────────────────────────────── # ── Public entry point ────────────────────────────────────────────────────────
def _collect_media_keys(form_data):
"""Return the set of 'uploads/...' storage keys referenced in form_data
(image field values, including any nested in lists/dicts). Signature values
are inline 'data:' base64 and are naturally excluded."""
keys = set()
def _walk(v):
if isinstance(v, str):
if v.startswith('uploads/'):
keys.add(v)
elif isinstance(v, dict):
for x in v.values():
_walk(x)
elif isinstance(v, (list, tuple)):
for x in v:
_walk(x)
_walk(form_data or {})
return keys
def generate_inspection_pdf(inspection, form_fields, form_data, issues, def generate_inspection_pdf(inspection, form_fields, form_data, issues,
static_folder) -> bytes: static_folder) -> bytes:
""" """
@@ -674,6 +695,14 @@ def generate_inspection_pdf(inspection, form_fields, form_data, issues,
generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET') generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET')
report_title = f'Inspection Report — {inspection.template.name}' report_title = f'Inspection Report — {inspection.template.name}'
# Make referenced photos available as local file paths for ReportLab/PIL.
# local backend: no-op (returns the real static folder); s3 backend:
# downloads the form's image keys to a temp dir. Cleaned up after build.
from app.utils import storage
static_folder, _cleanup_media = storage.materialize_to_dir(
_collect_media_keys(form_data)
)
doc = SimpleDocTemplate( doc = SimpleDocTemplate(
buf, buf,
pagesize=letter, pagesize=letter,
@@ -723,8 +752,11 @@ def generate_inspection_pdf(inspection, form_fields, form_data, issues,
])) ]))
story.append(sig_tbl) story.append(sig_tbl)
try:
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb) doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
return buf.getvalue() return buf.getvalue()
finally:
_cleanup_media()
# ══════════════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════════════
@@ -743,6 +775,14 @@ def generate_issue_pdf(issue, static_folder: str) -> bytes:
generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET') generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET')
report_title = f'Issue Report — Issue #{issue.id}' report_title = f'Issue Report — Issue #{issue.id}'
# Make referenced photos available as local file paths for ReportLab/PIL.
# local backend: no-op; s3 backend: downloads the issue's photo keys to a
# temp dir. Cleaned up after build.
from app.utils import storage
_issue_keys = [issue.photo_path] + list(issue.mobile_photo_paths or []) \
+ list(issue.result_photos or [])
static_folder, _cleanup_media = storage.materialize_to_dir(_issue_keys)
doc = SimpleDocTemplate( doc = SimpleDocTemplate(
buf, buf,
pagesize=letter, pagesize=letter,
@@ -936,8 +976,11 @@ def generate_issue_pdf(issue, static_folder: str) -> bytes:
])) ]))
story.append(v_tbl) story.append(v_tbl)
try:
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb) doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
return buf.getvalue() return buf.getvalue()
finally:
_cleanup_media()
# ══════════════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════════════
+371
View File
@@ -0,0 +1,371 @@
"""
app/utils/storage.py
---------------------
Storage abstraction for uploaded photo files (R2 object-storage migration).
Ported from the single-tenant tree (§22 Phase 1/2) with one multi-tenant
addition: per-tenant object-key prefixing. See "Tenant isolation" below.
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 MT-2 is a no-op.
- ``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.
Inert until MT-8 flips STORAGE_BACKEND per tenant.
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 or between
tenants, so there is **no schema migration** and no data rewrite.
Tenant isolation
----------------
Every tenant's DB holds keys in the same ``uploads/...`` namespace, so on a
shared object store those keys would collide across tenants. The prefix
``t<tenant_id>/`` is therefore applied **inside the S3 backend, on the wire**
(``_object_key``) never in the DB, never in a template, never in an API
payload. Callers stay tenant-agnostic; the DB stays portable.
DB value: uploads/issue_photos/ab12.jpg
R2 object: t3/uploads/issue_photos/ab12.jpg
The **local** backend deliberately does NOT prefix: its layout is the existing
on-disk tree, and prefixing would relocate every existing file (that is not a
no-op, and MT-2 must be one). Local mode therefore keeps today's shared
``app/static/uploads`` directory across tenants an isolation weakness that
predates this module and is retired when a tenant moves to ``s3`` in MT-8.
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)
materialize_to_dir(keys) -> (base_dir, cleanup)
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, g, 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'
def tenant_key_prefix():
"""Object-key prefix isolating one tenant's media from another's.
Returns ``'t<id>/'`` when multi-tenancy is enabled and a tenant is bound to
the current context, else ``''``.
The empty case covers three legitimate situations:
- MULTI_TENANT_ENABLED is false (plain single-tenant deploy),
- a cross-tenant cron/CLI context with no tenant bound,
- request contexts exempt from the tenant middleware.
Callers that must not silently write to an unprefixed key guard on this
themselves see ``S3Backend.save``.
"""
if not current_app.config.get('MULTI_TENANT_ENABLED'):
return ''
tenant = getattr(g, 'tenant', None) if g else None
return f't{tenant.id}/' if tenant is not None else ''
# ── Local filesystem backend (current behavior) ───────────────────────────────
class LocalBackend:
"""Files under ``app/static/uploads``, served by Flask/Nginx static route.
No tenant prefixing see module docstring ("Tenant isolation").
"""
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, external=False):
if not key:
return ''
# external=True yields an absolute URL (scheme+host) for API responses
# consumed off-origin (the iPad); templates call with external=False.
return url_for('static', filename=key, _external=external)
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
def materialize_to_dir(self, keys):
# Files already live under the static folder — no copy needed.
return self._static_folder(), (lambda: None)
# ── 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 == tenant prefix + the
DB path string (see module docstring).
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()
# ── Key mapping ──────────────────────────────────────────────────────────
def _object_key(self, key):
"""DB key -> on-the-wire object key (tenant-prefixed)."""
return f'{tenant_key_prefix()}{key}'
def _read_candidates(self, key):
"""Object keys to try when reading, most-specific first.
The unprefixed key is retained as a fallback so objects written before a
tenant prefix existed (or by a no-tenant context) stay readable.
"""
prefixed = self._object_key(key)
return [prefixed] if prefixed == key else [prefixed, key]
def _content_type(self, key):
return _CONTENT_TYPES.get(_ext_of(key), 'application/octet-stream')
# ── Interface ────────────────────────────────────────────────────────────
def save(self, file_obj, subfolder):
ext = _ext_of(file_obj.filename)
key = f'uploads/{subfolder}/{uuid.uuid4().hex}.{ext}'
# Fail loudly rather than write a key that could collide with another
# tenant's. A missing tenant here means the caller reached an upload
# path outside tenant resolution — a bug, not a fallback case.
if current_app.config.get('MULTI_TENANT_ENABLED') and not tenant_key_prefix():
raise RuntimeError(
'storage.save() called with STORAGE_BACKEND=s3 and no tenant '
'bound — refusing to write an unprefixed object key.'
)
file_obj.stream.seek(0)
body = file_obj.stream.read()
self._client.put_object(
Bucket=self.bucket, Key=self._object_key(key), Body=body,
ContentType=_CONTENT_TYPES.get(ext, 'application/octet-stream'),
)
# The DB always stores the unprefixed key.
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, external=False):
if not key:
return ''
obj = self._object_key(key)
if self.fallback and not self._head(obj):
# Object not at the tenant-prefixed key — try the legacy unprefixed
# key, then the local copy, during transition.
if obj != key and self._head(key):
obj = key
elif self._local.exists(key):
return self._local.media_url(key, external=external)
# Presigned R2 URLs are always absolute; the external flag is moot.
return self._client.generate_presigned_url(
'get_object',
Params={'Bucket': self.bucket, 'Key': obj},
ExpiresIn=self.ttl,
)
def read(self, key):
for candidate in self._read_candidates(key):
try:
resp = self._client.get_object(Bucket=self.bucket, Key=candidate)
return resp['Body'].read()
except Exception:
continue
# Transition safety: fall back to a local copy if present.
if self._local.exists(key):
return self._local.read(key)
raise FileNotFoundError(f'storage: no object for key {key!r}')
def _head(self, key):
try:
self._client.head_object(Bucket=self.bucket, Key=key)
return True
except Exception:
return False
def exists(self, key):
if not key:
return False
return any(self._head(k) for k in self._read_candidates(key))
def delete(self, key):
if not key:
return
for candidate in self._read_candidates(key):
try:
self._client.delete_object(Bucket=self.bucket, Key=candidate)
except Exception as e:
logger.warning('S3 delete failed for %s: %s', candidate, e)
def materialize_to_dir(self, keys):
"""Download the given keys to a temp dir mirroring the key layout.
Returns (base_dir, cleanup); each key resolves at base_dir/key so tools
that need real file paths (ReportLab/PIL) work unchanged. The temp tree
uses the **DB** key layout (unprefixed) because that is what callers
join against. Missing or unreadable keys are skipped callers already
guard with os.path.exists.
"""
import tempfile
import shutil
base = tempfile.mkdtemp(prefix='jqc_media_')
for key in {k for k in keys if k}:
try:
data = self.read(key)
except Exception:
continue # skip missing; caller guards existence
dest = os.path.join(base, key)
os.makedirs(os.path.dirname(dest), exist_ok=True)
with open(dest, 'wb') as fh:
fh.write(data)
def _cleanup():
shutil.rmtree(base, ignore_errors=True)
return base, _cleanup
_BACKENDS = {
'local': LocalBackend,
's3': S3Backend,
}
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, external=False):
return get_backend().media_url(key, external=external)
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)
def materialize_to_dir(keys):
return get_backend().materialize_to_dir(keys)
+18
View File
@@ -62,6 +62,24 @@ class Config:
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50 MB MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50 MB
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
# ── Storage backend (R2 migration, MT-2) ────────────────────────────────
# 'local' (default) = files under app/static/uploads, served via url_for('static').
# 's3' = Cloudflare R2 / S3-compatible. Flip via env only, per tenant, in MT-8.
# Object keys are tenant-prefixed inside the backend; DB paths never change.
STORAGE_BACKEND = os.environ.get('STORAGE_BACKEND', 'local')
# Cloudflare R2 — only used when STORAGE_BACKEND=s3 (inert until MT-8).
R2_ENDPOINT_URL = os.environ.get('R2_ENDPOINT_URL')
R2_ACCESS_KEY_ID = os.environ.get('R2_ACCESS_KEY_ID')
R2_SECRET_ACCESS_KEY = os.environ.get('R2_SECRET_ACCESS_KEY')
R2_BUCKET = os.environ.get('R2_BUCKET')
R2_PRESIGN_TTL = int(os.environ.get('R2_PRESIGN_TTL', '86400')) # seconds (24h)
# When true, media_url HEADs the object and falls back to the legacy
# unprefixed key, then a local static URL, if it's missing (belt-and-braces
# during an early/partial cutover). Off by default — cutover is gated on
# full verification, so objects exist.
R2_MEDIA_FALLBACK = os.environ.get('R2_MEDIA_FALLBACK', 'false').lower() == 'true'
# ── Session / cookies ─────────────────────────────────────────────────── # ── Session / cookies ───────────────────────────────────────────────────
PERMANENT_SESSION_LIFETIME = timedelta(hours=24) PERMANENT_SESSION_LIFETIME = timedelta(hours=24)
# Secure by default — subclasses must explicitly opt out for local dev. # Secure by default — subclasses must explicitly opt out for local dev.