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 @@ + +
+

Email (SMTP) Settings

+

These settings are used to send confirmation, preview, and delivery emails to customers. The password is stored encrypted using the Flask secret key.

+
+ + + + + + + + + + + + + + + + + + + + + + +
SMTP Host
SMTP Port
SMTP Username
From Address
SMTP Password + +
+ +
+
+

MP3 Metadata Tags