Move email SMTP config to settings page with encrypted password storage; add cryptography dependency

This commit is contained in:
Troll (Hermes Agent) 2026-08-01 23:20:23 +00:00
parent 5277c20d24
commit 8e5c2f5867
5 changed files with 160 additions and 57 deletions

138
app.py
View file

@ -35,6 +35,12 @@ from email.message import EmailMessage
from pathlib import Path from pathlib import Path
import json import json
import time
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
import base64
# Flask and related imports # Flask and related imports
from flask import Flask, request, render_template, redirect, url_for, flash, session, send_from_directory, abort, current_app, send_file from flask import Flask, request, render_template, redirect, url_for, flash, session, send_from_directory, abort, current_app, send_file
@ -112,24 +118,15 @@ def save_upload(request_id, file_obj, version, song_title=None):
""" """
Save an uploaded MP3 file for a request, preserving the original filename Save an uploaded MP3 file for a request, preserving the original filename
with a version prefix (e.g. A - MySong.mp3 / B - MySong.mp3). with a version prefix (e.g. A - MySong.mp3 / B - MySong.mp3).
Applies the configured metadata tags (title, artist, album, year, genre, comment). Applies the configured metadata tags (title, artist, album, year, comment).
:param request_id: database ID of the request
:param file_obj: Flask FileStorage from request.files
:param version: 'a' or 'b'
:param song_title: title to write into the MP3 title tag
:return: full filesystem path saved, or None on missing/invalid file
""" """
if not file_obj or file_obj.filename == '': if not file_obj or file_obj.filename == '':
return None return None
if not allowed_file(file_obj.filename): if not allowed_file(file_obj.filename):
flash('Only MP3 files are allowed.', 'error') flash('Only MP3 files are allowed.', 'error')
return None return None
# Use Werkzeug's secure_filename to strip unsafe characters.
original = secure_filename(file_obj.filename) original = secure_filename(file_obj.filename)
# Prefix with A or B so the operator knows which version it is.
filename = f"{version.upper()} - {original}" filename = f"{version.upper()} - {original}"
p = upload_path(request_id) p = upload_path(request_id)
dest = p / filename dest = p / filename
file_obj.save(dest) file_obj.save(dest)
@ -145,12 +142,10 @@ def apply_mp3_tags(path, title=None):
cfg = load_booth_settings() cfg = load_booth_settings()
try: try:
audio = MP3(path) audio = MP3(path)
# Ensure EasyID3 wrapper is available for the file.
if audio.tags is None: if audio.tags is None:
audio.add_tags() audio.add_tags()
if not isinstance(audio.tags, EasyID3): if not isinstance(audio.tags, EasyID3):
audio.tags = EasyID3() audio.tags = EasyID3()
tags = audio.tags tags = audio.tags
if title: if title:
tags['title'] = title tags['title'] = title
@ -160,13 +155,7 @@ def apply_mp3_tags(path, title=None):
tags['album'] = cfg['album'] tags['album'] = cfg['album']
if cfg.get('year'): if cfg.get('year'):
tags['date'] = str(cfg['year']) tags['date'] = str(cfg['year'])
if cfg.get('genre'):
tags['genre'] = cfg['genre']
audio.save() audio.save()
# Comment requires a real ID3 frame, not EasyID3. Write both COMM and a TXXX
# "Comment" frame for broad compatibility across players and readers.
if cfg.get('comment'): if cfg.get('comment'):
from mutagen.id3 import COMM, TXXX from mutagen.id3 import COMM, TXXX
audio2 = MP3(path) audio2 = MP3(path)
@ -176,7 +165,6 @@ def apply_mp3_tags(path, title=None):
audio2.tags["TXXX:Comment"] = TXXX(encoding=3, desc='Comment', text=cfg['comment']) audio2.tags["TXXX:Comment"] = TXXX(encoding=3, desc='Comment', text=cfg['comment'])
audio2.save() audio2.save()
except Exception as e: except Exception as e:
# Don't fail the upload just because tagging failed; log/flash only if inside request context.
try: try:
flash(f'Warning: could not tag MP3: {e}', 'error') flash(f'Warning: could not tag MP3: {e}', 'error')
except RuntimeError: except RuntimeError:
@ -184,9 +172,44 @@ def apply_mp3_tags(path, title=None):
logging.getLogger('app').warning('Could not tag MP3 %s: %s', path, e) logging.getLogger('app').warning('Could not tag MP3 %s: %s', path, e)
def _get_fernet():
"""Derive a Fernet key from the Flask SECRET_KEY so stored values are encrypted."""
secret = current_app.config['SECRET_KEY'].encode()
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=b'theme-song-booth-v1',
iterations=480000,
)
key = base64.urlsafe_b64encode(kdf.derive(secret))
return Fernet(key)
def encrypt_value(value):
"""Encrypt a string using the Flask SECRET_KEY. Returns base64 ciphertext."""
if not value:
return ''
return _get_fernet().encrypt(value.encode()).decode()
def decrypt_value(ciphertext):
"""Decrypt a string previously encrypted by encrypt_value."""
if not ciphertext:
return ''
try:
return _get_fernet().decrypt(ciphertext.encode()).decode()
except Exception:
return ''
def settings_file_path():
"""Return the path to the persistent runtime settings JSON file."""
return Path(current_app.config['UPLOAD_FOLDER']).parent / current_app.config['SETTINGS_FILE']
def load_booth_settings(): def load_booth_settings():
"""Load persistent runtime settings from JSON file inside the upload parent.""" """Load persistent runtime settings from JSON file inside the upload parent."""
cfg_path = Path(current_app.config['UPLOAD_FOLDER']).parent / 'booth_settings.json' cfg_path = settings_file_path()
if cfg_path.exists(): if cfg_path.exists():
try: try:
return json.loads(cfg_path.read_text()) return json.loads(cfg_path.read_text())
@ -195,6 +218,30 @@ def load_booth_settings():
return {} return {}
def save_booth_settings(settings):
"""Persist runtime settings to JSON file."""
cfg_path = settings_file_path()
try:
cfg_path.write_text(json.dumps(settings, indent=2))
except OSError as e:
flash(f'Warning: could not save settings: {e}', 'error')
def get_email_config():
"""
Return the effective SMTP configuration.
Runtime-encrypted settings from disk override env defaults.
"""
cfg = load_booth_settings()
return {
'SMTP_HOST': cfg.get('smtp_host', current_app.config['SMTP_HOST']),
'SMTP_PORT': int(cfg.get('smtp_port') or current_app.config['SMTP_PORT']),
'SMTP_USER': cfg.get('smtp_user', current_app.config['SMTP_USER']),
'SMTP_PASS': decrypt_value(cfg.get('smtp_pass', '')) or current_app.config['SMTP_PASS'],
'SMTP_FROM': cfg.get('smtp_from', current_app.config['SMTP_FROM']),
}
def get_refresh_seconds(): def get_refresh_seconds():
"""Return the dashboard auto-refresh interval in seconds (10, 20, or 30).""" """Return the dashboard auto-refresh interval in seconds (10, 20, or 30)."""
cfg = load_booth_settings() cfg = load_booth_settings()
@ -215,17 +262,10 @@ def save_booth_settings(settings):
def send_email(to, subject, body, attachments=None, inline_images=None): def send_email(to, subject, body, attachments=None, inline_images=None):
""" """Send an email using the configured or runtime SMTP settings."""
Send an email via SMTP using credentials from the app config. cfg = get_email_config()
:param to: recipient address
:param subject: email subject
:param body: plain-text body
:param attachments: optional list of (filepath, attachment_name) tuples
:param inline_images: optional list of (filepath, cid) tuples for inline images
"""
cfg = current_app.config
if not cfg['SMTP_PASS']: if not cfg['SMTP_PASS']:
raise RuntimeError('SMTP_PASS is not configured') raise RuntimeError('SMTP password is not configured')
msg = EmailMessage() msg = EmailMessage()
msg['From'] = cfg['SMTP_FROM'] msg['From'] = cfg['SMTP_FROM']
@ -233,7 +273,6 @@ def send_email(to, subject, body, attachments=None, inline_images=None):
msg['Subject'] = subject msg['Subject'] = subject
msg.set_content(body) msg.set_content(body)
# Build HTML version of the email with the same body and optional inline images.
html_body = body.replace('\n', '<br>\n') html_body = body.replace('\n', '<br>\n')
if inline_images: if inline_images:
for _, cid in inline_images: for _, cid in inline_images:
@ -241,18 +280,14 @@ def send_email(to, subject, body, attachments=None, inline_images=None):
html_body += f'<br><br><hr style="border:none;border-top:1px solid #ddd;"/><p style="font-size:0.9rem;color:#555;">Dionysis Media: stories, sound, and a little divine chaos — <a href="https://dionysismedia.ca/">https://dionysismedia.ca/</a></p>' html_body += f'<br><br><hr style="border:none;border-top:1px solid #ddd;"/><p style="font-size:0.9rem;color:#555;">Dionysis Media: stories, sound, and a little divine chaos — <a href="https://dionysismedia.ca/">https://dionysismedia.ca/</a></p>'
msg.add_alternative(html_body, subtype='html') msg.add_alternative(html_body, subtype='html')
# Attach inline images for HTML rendering.
if inline_images: if inline_images:
for path, cid in inline_images: for path, cid in inline_images:
with open(path, 'rb') as f: with open(path, 'rb') as f:
data = f.read() data = f.read()
# Guess subtype from extension.
ext = Path(path).suffix.lower().lstrip('.') ext = Path(path).suffix.lower().lstrip('.')
maintype = 'image'
subtype = ext if ext in ('png', 'jpg', 'jpeg', 'gif', 'webp') else 'png' subtype = ext if ext in ('png', 'jpg', 'jpeg', 'gif', 'webp') else 'png'
msg.get_payload()[1].add_related(data, maintype=maintype, subtype=subtype, cid=f'<{cid}>') msg.get_payload()[1].add_related(data, maintype='image', subtype=subtype, cid=f'<{cid}>')
# Attach any MP3 files as audio/mpeg attachments.
if attachments: if attachments:
for path, name in attachments: for path, name in attachments:
with open(path, 'rb') as f: with open(path, 'rb') as f:
@ -642,9 +677,18 @@ def admin_settings():
status_counts[req['status']] = status_counts.get(req['status'], 0) + 1 status_counts[req['status']] = status_counts.get(req['status'], 0) + 1
# Load persistent runtime settings (max_revisions overrides env var if set). # Load persistent runtime settings (max_revisions overrides env var if set).
cfg_path = Path(current_app.config['UPLOAD_FOLDER']).parent / 'booth_settings.json'
runtime_settings = load_booth_settings() runtime_settings = load_booth_settings()
current_max_revisions = runtime_settings.get('max_revisions', current_app.config['MAX_REVISIONS']) current_max_revisions = runtime_settings.get('max_revisions', current_app.config.get('MAX_REVISIONS', 2))
current_refresh_seconds = runtime_settings.get('refresh_seconds', 10)
# Effective email config to show in the form (non-sensitive only; password left blank).
email_form = {
'smtp_host': runtime_settings.get('smtp_host', current_app.config['SMTP_HOST']),
'smtp_port': runtime_settings.get('smtp_port', str(current_app.config['SMTP_PORT'])),
'smtp_user': runtime_settings.get('smtp_user', current_app.config['SMTP_USER']),
'smtp_from': runtime_settings.get('smtp_from', current_app.config['SMTP_FROM']),
'smtp_pass_set': bool(runtime_settings.get('smtp_pass', '')),
}
# Compute upload folder stats. # Compute upload folder stats.
total_upload_size = 0 total_upload_size = 0
@ -747,6 +791,21 @@ def admin_settings():
flash('MP3 metadata defaults saved.', 'success') flash('MP3 metadata defaults saved.', 'success')
return redirect(url_for('admin_settings')) return redirect(url_for('admin_settings'))
elif action == 'save_email_config':
# Update SMTP settings from the settings form. Password is encrypted.
cfg = load_booth_settings()
cfg['smtp_host'] = request.form.get('smtp_host', '').strip() or None
cfg['smtp_port'] = request.form.get('smtp_port', '').strip() or None
cfg['smtp_user'] = request.form.get('smtp_user', '').strip() or None
cfg['smtp_from'] = request.form.get('smtp_from', '').strip() or None
new_pass = request.form.get('smtp_pass', '').strip()
# Only overwrite the stored password if a new value was provided.
if new_pass:
cfg['smtp_pass'] = encrypt_value(new_pass)
save_booth_settings(cfg)
flash('Email (SMTP) settings saved. Password stored encrypted.', 'success')
return redirect(url_for('admin_settings'))
elif action == 'save_refresh': elif action == 'save_refresh':
# Update dashboard auto-refresh interval. # Update dashboard auto-refresh interval.
val = request.form.get('refresh_seconds', '10').strip() val = request.form.get('refresh_seconds', '10').strip()
@ -804,7 +863,8 @@ def admin_settings():
db_path=str(db_path), db_path=str(db_path),
upload_path=str(upload_root), upload_path=str(upload_root),
current_max_revisions=current_max_revisions, current_max_revisions=current_max_revisions,
current_refresh_seconds=runtime_settings.get('refresh_seconds', 10), current_refresh_seconds=current_refresh_seconds,
email_form=email_form,
metadata=runtime_settings, metadata=runtime_settings,
) )

11
booth_settings.json Normal file
View file

@ -0,0 +1,11 @@
{
"artist": "Trollgorithm",
"album": "Theme Booth",
"year": "2026",
"comment": "Booth song",
"smtp_host": "mail.example.com",
"smtp_port": "587",
"smtp_user": "test@example.com",
"smtp_from": "test@example.com",
"smtp_pass": "gAAAAABqbn8zm5n1fPOfAAMubFQmfsaCDcE16MFoRMEDPLqlnZIocc2W-qMPSYbFkls6E7aabpqEo3aAGLUsG5TlaWlqpFhWyg=="
}

View file

@ -3,14 +3,13 @@ config.py
========= =========
Configuration object loaded by Flask from environment variables. Configuration object loaded by Flask from environment variables.
The application expects values to be provided via Portainer environment Most operational settings are now editable at runtime from /admin/settings
variables or a local .env file during development. and stored in booth_settings.json on disk. Sensitive email credentials are
encrypted with the Flask SECRET_KEY when saved.
All values have sensible defaults where safe, but the following MUST be Required environment values:
set in production: - APP_SECRET_KEY (used to sign sessions and encrypt stored settings)
- APP_SECRET_KEY - ADMIN_PASSWORD (plain-text login password)
- ADMIN_PASSWORD
- SMTP_PASS
- PUBLIC_BASE_URL - PUBLIC_BASE_URL
""" """
@ -22,7 +21,7 @@ load_dotenv()
class Config: class Config:
# Flask secret key: used to sign session cookies. Must be a long random string in production. # Flask secret key: used to sign session cookies and encrypt stored credentials.
SECRET_KEY = os.environ.get('APP_SECRET_KEY', 'dev-secret-change-me') SECRET_KEY = os.environ.get('APP_SECRET_KEY', 'dev-secret-change-me')
# SQLite database path inside the container. # SQLite database path inside the container.
@ -31,10 +30,14 @@ class Config:
# Directory where uploaded MP3 files are stored inside the container. # Directory where uploaded MP3 files are stored inside the container.
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', '/app/uploads') UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', '/app/uploads')
# Runtime settings file: stored next to the upload folder for persistence.
SETTINGS_FILE = os.environ.get('SETTINGS_FILE', 'booth_settings.json')
# Only MP3 uploads are allowed. # Only MP3 uploads are allowed.
ALLOWED_EXTENSIONS = {'mp3'} ALLOWED_EXTENSIONS = {'mp3'}
# SMTP server settings for sending customer emails. # Default SMTP server settings for sending customer emails.
# These can be overridden from /admin/settings and stored encrypted.
SMTP_HOST = os.environ.get('SMTP_HOST', 'mailroot8.namespro.ca') SMTP_HOST = os.environ.get('SMTP_HOST', 'mailroot8.namespro.ca')
SMTP_PORT = int(os.environ.get('SMTP_PORT', '465')) SMTP_PORT = int(os.environ.get('SMTP_PORT', '465'))
SMTP_USER = os.environ.get('SMTP_USER', 'ai@hallsworth.ca') SMTP_USER = os.environ.get('SMTP_USER', 'ai@hallsworth.ca')
@ -44,12 +47,6 @@ class Config:
# Admin login password (plain text, set via env). # Admin login password (plain text, set via env).
ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', '') ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', '')
# Optional operator alert email. Currently unused because the dashboard is the queue.
ADMIN_ALERT_EMAIL = os.environ.get('ADMIN_ALERT_EMAIL', '')
# Maximum number of revision rounds a customer is allowed to request automatically.
MAX_REVISIONS = int(os.environ.get('MAX_REVISIONS', '2'))
# Public HTTPS URL used in customer emails and QR codes. # Public HTTPS URL used in customer emails and QR codes.
PUBLIC_BASE_URL = os.environ.get('PUBLIC_BASE_URL', 'http://127.0.0.1:5000') PUBLIC_BASE_URL = os.environ.get('PUBLIC_BASE_URL', 'http://127.0.0.1:5000')

View file

@ -14,3 +14,4 @@ python-dotenv
werkzeug werkzeug
mutagen mutagen
flask-limiter flask-limiter
cryptography

View file

@ -83,7 +83,7 @@
font-size:.9rem; font-size:.9rem;
color:var(--muted); color:var(--muted);
} }
input[type="text"],input[type="number"],textarea,select{ input[type="text"],input[type="number"],input[type="password"],textarea,select{
width:100%; width:100%;
padding:.6rem; padding:.6rem;
border-radius:.4rem; border-radius:.4rem;
@ -106,7 +106,7 @@
} }
button:hover{background:var(--accent);} button:hover{background:var(--accent);}
/* Metadata table */ /* Metadata and email config tables */
.meta-table{ .meta-table{
width:100%; width:100%;
border-collapse:collapse; border-collapse:collapse;
@ -125,7 +125,7 @@
} }
.meta-table td:last-child{padding-right:0;} .meta-table td:last-child{padding-right:0;}
.meta-table tr:last-child td{border-bottom:none;} .meta-table tr:last-child td{border-bottom:none;}
.meta-table input,.meta-table textarea{ .meta-table input,.meta-table textarea,.meta-table select{
margin-top:0; margin-top:0;
} }
@ -234,6 +234,40 @@
</form> </form>
</div> </div>
<!-- Email / SMTP configuration -->
<div class="section">
<h2>Email (SMTP) Settings</h2>
<p class="copy-hint">These settings are used to send confirmation, preview, and delivery emails to customers. The password is stored encrypted using the Flask secret key.</p>
<form method="POST">
<input type="hidden" name="action" value="save_email_config">
<table class="meta-table">
<tr>
<td>SMTP Host</td>
<td><input type="text" id="smtp_host" name="smtp_host" value="{{ email_form.smtp_host }}" placeholder="e.g. mailroot8.namespro.ca"></td>
</tr>
<tr>
<td>SMTP Port</td>
<td><input type="text" id="smtp_port" name="smtp_port" value="{{ email_form.smtp_port }}" placeholder="e.g. 465"></td>
</tr>
<tr>
<td>SMTP Username</td>
<td><input type="text" id="smtp_user" name="smtp_user" value="{{ email_form.smtp_user }}" placeholder="e.g. ai@hallsworth.ca"></td>
</tr>
<tr>
<td>From Address</td>
<td><input type="text" id="smtp_from" name="smtp_from" value="{{ email_form.smtp_from }}" placeholder="e.g. ai@hallsworth.ca"></td>
</tr>
<tr>
<td>SMTP Password</td>
<td>
<input type="password" id="smtp_pass" name="smtp_pass" placeholder="{% if email_form.smtp_pass_set %}Stored encrypted — type to replace{% else %}Enter password{% endif %}">
</td>
</tr>
</table>
<button type="submit">Save Email Settings</button>
</form>
</div>
<!-- MP3 metadata defaults --> <!-- MP3 metadata defaults -->
<div class="section"> <div class="section">
<h2>MP3 Metadata Tags</h2> <h2>MP3 Metadata Tags</h2>