diff --git a/app.py b/app.py
index 25441bb..614c745 100644
--- a/app.py
+++ b/app.py
@@ -35,6 +35,12 @@ from email.message import EmailMessage
from pathlib import Path
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
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
with a version prefix (e.g. A - MySong.mp3 / B - MySong.mp3).
- Applies the configured metadata tags (title, artist, album, year, genre, 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
+ Applies the configured metadata tags (title, artist, album, year, comment).
"""
if not file_obj or file_obj.filename == '':
return None
if not allowed_file(file_obj.filename):
flash('Only MP3 files are allowed.', 'error')
return None
-
- # Use Werkzeug's secure_filename to strip unsafe characters.
original = secure_filename(file_obj.filename)
- # Prefix with A or B so the operator knows which version it is.
filename = f"{version.upper()} - {original}"
-
p = upload_path(request_id)
dest = p / filename
file_obj.save(dest)
@@ -145,12 +142,10 @@ def apply_mp3_tags(path, title=None):
cfg = load_booth_settings()
try:
audio = MP3(path)
- # Ensure EasyID3 wrapper is available for the file.
if audio.tags is None:
audio.add_tags()
if not isinstance(audio.tags, EasyID3):
audio.tags = EasyID3()
-
tags = audio.tags
if title:
tags['title'] = title
@@ -160,13 +155,7 @@ def apply_mp3_tags(path, title=None):
tags['album'] = cfg['album']
if cfg.get('year'):
tags['date'] = str(cfg['year'])
- if cfg.get('genre'):
- tags['genre'] = cfg['genre']
-
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'):
from mutagen.id3 import COMM, TXXX
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.save()
except Exception as e:
- # Don't fail the upload just because tagging failed; log/flash only if inside request context.
try:
flash(f'Warning: could not tag MP3: {e}', 'error')
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)
+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():
"""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():
try:
return json.loads(cfg_path.read_text())
@@ -195,6 +218,30 @@ def load_booth_settings():
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():
"""Return the dashboard auto-refresh interval in seconds (10, 20, or 30)."""
cfg = load_booth_settings()
@@ -215,17 +262,10 @@ def save_booth_settings(settings):
def send_email(to, subject, body, attachments=None, inline_images=None):
- """
- Send an email via SMTP using credentials from the app 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
+ """Send an email using the configured or runtime SMTP settings."""
+ cfg = get_email_config()
if not cfg['SMTP_PASS']:
- raise RuntimeError('SMTP_PASS is not configured')
+ raise RuntimeError('SMTP password is not configured')
msg = EmailMessage()
msg['From'] = cfg['SMTP_FROM']
@@ -233,7 +273,6 @@ def send_email(to, subject, body, attachments=None, inline_images=None):
msg['Subject'] = subject
msg.set_content(body)
- # Build HTML version of the email with the same body and optional inline images.
html_body = body.replace('\n', '
\n')
if 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'
Dionysis Media: stories, sound, and a little divine chaos — https://dionysismedia.ca/
' msg.add_alternative(html_body, subtype='html') - # Attach inline images for HTML rendering. if inline_images: for path, cid in inline_images: with open(path, 'rb') as f: data = f.read() - # Guess subtype from extension. ext = Path(path).suffix.lower().lstrip('.') - maintype = 'image' 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: for path, name in attachments: with open(path, 'rb') as f: @@ -642,9 +677,18 @@ def admin_settings(): status_counts[req['status']] = status_counts.get(req['status'], 0) + 1 # 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() - 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. total_upload_size = 0 @@ -747,6 +791,21 @@ def admin_settings(): flash('MP3 metadata defaults saved.', 'success') 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': # Update dashboard auto-refresh interval. val = request.form.get('refresh_seconds', '10').strip() @@ -804,7 +863,8 @@ def admin_settings(): db_path=str(db_path), upload_path=str(upload_root), 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, ) diff --git a/booth_settings.json b/booth_settings.json new file mode 100644 index 0000000..b6d3c17 --- /dev/null +++ b/booth_settings.json @@ -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==" +} \ No newline at end of file diff --git a/config.py b/config.py index b45af65..6a62092 100644 --- a/config.py +++ b/config.py @@ -3,14 +3,13 @@ config.py ========= Configuration object loaded by Flask from environment variables. -The application expects values to be provided via Portainer environment -variables or a local .env file during development. +Most operational settings are now editable at runtime from /admin/settings +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 -set in production: - - APP_SECRET_KEY - - ADMIN_PASSWORD - - SMTP_PASS +Required environment values: + - APP_SECRET_KEY (used to sign sessions and encrypt stored settings) + - ADMIN_PASSWORD (plain-text login password) - PUBLIC_BASE_URL """ @@ -22,7 +21,7 @@ load_dotenv() 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') # SQLite database path inside the container. @@ -31,10 +30,14 @@ class Config: # Directory where uploaded MP3 files are stored inside the container. 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. 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_PORT = int(os.environ.get('SMTP_PORT', '465')) 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_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_BASE_URL = os.environ.get('PUBLIC_BASE_URL', 'http://127.0.0.1:5000') diff --git a/requirements.txt b/requirements.txt index af86fe5..5f37eae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,4 @@ python-dotenv werkzeug mutagen flask-limiter +cryptography diff --git a/templates/admin/settings.html b/templates/admin/settings.html index 7295698..178145c 100644 --- a/templates/admin/settings.html +++ b/templates/admin/settings.html @@ -83,7 +83,7 @@ font-size:.9rem; color:var(--muted); } - input[type="text"],input[type="number"],textarea,select{ + input[type="text"],input[type="number"],input[type="password"],textarea,select{ width:100%; padding:.6rem; border-radius:.4rem; @@ -106,7 +106,7 @@ } button:hover{background:var(--accent);} - /* Metadata table */ + /* Metadata and email config tables */ .meta-table{ width:100%; border-collapse:collapse; @@ -125,7 +125,7 @@ } .meta-table td:last-child{padding-right:0;} .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; } @@ -234,6 +234,40 @@ + +These settings are used to send confirmation, preview, and delivery emails to customers. The password is stored encrypted using the Flask secret key.
+ +