Aug 21 - MT22 logo seam fix
This commit is contained in:
@@ -29,6 +29,7 @@ from app.models.tenant_settings import TenantSettings
|
||||
from app.utils.decorators import admin_required
|
||||
from app.utils.audit import log_action, ACTION_UPDATE, ACTION_CREATE, ACTION_DELETE
|
||||
from app.utils.time_utils import now_eastern
|
||||
from app.utils import storage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,7 +44,19 @@ _DOMAIN_RE = re.compile(
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _save_logo(file_obj):
|
||||
"""Save uploaded logo to static/uploads/logos/; return relative URL or None."""
|
||||
"""Save an uploaded logo via the storage seam; return its key or None.
|
||||
|
||||
MT-22: this previously wrote straight to ``UPLOAD_FOLDER/logos/`` with
|
||||
``file_obj.save()`` — the last direct-to-disk writer in the app. On an
|
||||
R2-backed tenant the logo never reached the bucket, and on the local
|
||||
backend every tenant's logo landed in one shared directory.
|
||||
|
||||
Validation stays here by design: ``storage.py`` only moves bytes, and
|
||||
callers own extension / magic-byte checks (see its module docstring).
|
||||
|
||||
The returned key keeps the exact ``uploads/logos/<file>`` shape already
|
||||
stored in ``TenantSettings.logo_url``, so no migration and no data rewrite.
|
||||
"""
|
||||
if not file_obj or not file_obj.filename:
|
||||
return None
|
||||
allowed = {'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'}
|
||||
@@ -63,27 +76,22 @@ def _save_logo(file_obj):
|
||||
}
|
||||
if not any(header.startswith(m) for m in magic):
|
||||
return None
|
||||
import secrets
|
||||
logos_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'logos')
|
||||
os.makedirs(logos_dir, exist_ok=True)
|
||||
filename = f'{secrets.token_hex(12)}.{ext}'
|
||||
file_obj.save(os.path.join(logos_dir, filename))
|
||||
return f'uploads/logos/{filename}'
|
||||
file_obj.seek(0)
|
||||
return storage.save(file_obj, 'logos')
|
||||
|
||||
|
||||
def _delete_logo(logo_url):
|
||||
"""Remove a logo file from disk. Silently ignores missing files.
|
||||
Safety guard: only deletes files inside the uploads/logos/ subfolder."""
|
||||
"""Remove a stored logo. Silently ignores missing objects.
|
||||
Safety guard: only deletes keys inside the uploads/logos/ subfolder."""
|
||||
if not logo_url:
|
||||
return # nothing stored — never issue a delete for 'uploads/logos/'
|
||||
try:
|
||||
# Use only the basename to avoid any path-traversal via the stored URL.
|
||||
# All logos are written into logos_dir by _save_logo(), so joining the
|
||||
# basename back to that directory is always the correct path.
|
||||
logos_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'logos')
|
||||
abs_logos = os.path.abspath(logos_dir)
|
||||
abs_path = os.path.join(abs_logos, os.path.basename(logo_url))
|
||||
if os.path.isfile(abs_path):
|
||||
os.remove(abs_path)
|
||||
logger.info('SETTINGS | logo_deleted | path=%s', abs_path)
|
||||
# All logos are written to 'uploads/logos/' by _save_logo(), so
|
||||
# rebuilding the key from the basename is always the correct target.
|
||||
key = f'uploads/logos/{os.path.basename(str(logo_url or ""))}'
|
||||
storage.delete(key)
|
||||
logger.info('SETTINGS | logo_deleted | key=%s', key)
|
||||
except Exception as exc:
|
||||
logger.warning('SETTINGS | logo_delete_failed | url=%s err=%s', logo_url, exc)
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('dashboard.index') }}">
|
||||
{% if tenant_branding and tenant_branding.logo_url %}
|
||||
<img src="{{ url_for('static', filename=tenant_branding.logo_url) }}"
|
||||
<img src="{{ media_url(tenant_branding.logo_url) }}"
|
||||
alt="{{ tenant_branding.display_name }}"
|
||||
style="max-height:32px; border-radius:4px; margin-right:.35rem;">
|
||||
{% else %}
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
|
||||
<a class="jqc-brand" href="{{ url_for('dashboard.index') }}">
|
||||
{% if tenant_branding and tenant_branding.logo_url %}
|
||||
<img src="{{ url_for('static', filename=tenant_branding.logo_url) }}"
|
||||
<img src="{{ media_url(tenant_branding.logo_url) }}"
|
||||
alt="{{ tenant_branding.display_name }}" class="jqc-brand-logo">
|
||||
{% else %}
|
||||
<span class="jqc-brand-mark">JQC</span>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<label class="form-label fw-semibold">Logo</label>
|
||||
{% if settings.logo_url %}
|
||||
<div class="mb-2">
|
||||
<img src="{{ url_for('static', filename=settings.logo_url) }}"
|
||||
<img src="{{ media_url(settings.logo_url) }}"
|
||||
alt="Current logo" style="max-height:48px; border-radius:6px;">
|
||||
<div class="form-check mt-1">
|
||||
<input class="form-check-input" type="checkbox" name="clear_logo" id="clear_logo">
|
||||
@@ -105,7 +105,7 @@
|
||||
<nav class="navbar navbar-dark px-3 py-2" id="preview-navbar"
|
||||
style="background-color: {{ settings.primary_color or '#1a56db' }}; border-radius:0 0 6px 6px;">
|
||||
{% if settings.logo_url %}
|
||||
<img src="{{ url_for('static', filename=settings.logo_url) }}"
|
||||
<img src="{{ media_url(settings.logo_url) }}"
|
||||
alt="logo" style="max-height:32px; margin-right:.5rem; border-radius:4px;">
|
||||
{% else %}
|
||||
<i class="bi bi-clipboard-check me-2"></i>
|
||||
|
||||
@@ -155,6 +155,10 @@ class LocalBackend:
|
||||
_CONTENT_TYPES = {
|
||||
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
|
||||
'png': 'image/png', 'gif': 'image/gif',
|
||||
# MT-22: logos accept these two as well. Without an entry here they would
|
||||
# be stored as application/octet-stream, which a presigned <img> GET may
|
||||
# refuse to render.
|
||||
'webp': 'image/webp', 'svg': 'image/svg+xml',
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user