diff --git a/.flaskenv b/.flaskenv index 8c7a5d0..1a2d37f 100644 --- a/.flaskenv +++ b/.flaskenv @@ -1,9 +1,9 @@ FLASK_APP=app:create_app FLASK_DEBUG=1 -SMTP_HOST=smtp.strato.de -SMTP_PORT=465 -SMTP_USERNAME=beheer@alphen.cloud -SMTP_PASSWORD=Fr@nkrijk2024! -SMTP_FROM=beheer@alphen.cloud -SMTP_STARTTLS=1 -SMTP_DEBUG=1 \ No newline at end of file +##SMTP_HOST=smtp.strato.de +##SMTP_PORT=465 +##SMTP_USERNAME=beheer@alphen.cloud +##SMTP_PASSWORD=*** +##SMTP_FROM=beheer@alphen.cloud +##SMTP_STARTTLS=1 +##SMTP_DEBUG=1 \ No newline at end of file diff --git a/README.md b/README.md index 986e98d..1e43d49 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,15 @@ The player keeps itself up-to-date automatically: This project includes a simple **forgot password** flow. SMTP configuration is read from environment variables. +You can also configure SMTP settings from the UI: **Admin → Settings**. +Environment variables still take precedence over the database settings. + +### Public domain for emails + +If your app runs behind a reverse proxy (or the internal hostname differs from the public hostname), +set **Admin → Settings → Public domain** to e.g. `signage.example.com` so links in password reset +emails point to the correct address. + Recommended: put these in a local `.env` file in the repo root. Flask (via `python-dotenv`) will auto-load it on startup. `.env` is already gitignored. You can start from `.env.example`: @@ -81,6 +90,11 @@ REM Option B: put the same keys/values in a .env file instead Security note: do **not** commit SMTP passwords to the repo. Prefer secrets management and rotate leaked credentials. +Note on the "From" address: some SMTP providers enforce that the authenticated mailbox +(`SMTP_USERNAME`) is used as the actual sender (envelope-from), even if a different +`SMTP_FROM` is provided. In that case the app sets a `Reply-To` header so replies still +go to `SMTP_FROM`, but the provider may still show the username address as the sender. + ### Troubleshooting mail delivery If the reset email is not received: @@ -104,3 +118,6 @@ If the reset email is not received: + + + diff --git a/app/__init__.py b/app/__init__.py index a7f8810..3a762ff 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -3,7 +3,7 @@ from flask import Flask, jsonify, request from werkzeug.exceptions import RequestEntityTooLarge from .extensions import db, login_manager -from .models import User +from .models import AppSettings, User from .cli import init_db_command @@ -64,6 +64,19 @@ def create_app(): if "storage_max_bytes" not in company_cols: db.session.execute(db.text("ALTER TABLE company ADD COLUMN storage_max_bytes BIGINT")) db.session.commit() + + # AppSettings: create settings table if missing. + # (PRAGMA returns empty if the table doesn't exist.) + settings_cols = [ + r[1] for r in db.session.execute(db.text("PRAGMA table_info(app_settings)")).fetchall() + ] + if not settings_cols: + AppSettings.__table__.create(db.engine, checkfirst=True) + + # AppSettings: add public_domain column if missing. + if settings_cols and "public_domain" not in settings_cols: + db.session.execute(db.text("ALTER TABLE app_settings ADD COLUMN public_domain VARCHAR(255)")) + db.session.commit() except Exception: db.session.rollback() diff --git a/app/cli.py b/app/cli.py index d3ebcbf..9aaa8ed 100644 --- a/app/cli.py +++ b/app/cli.py @@ -2,7 +2,7 @@ import click from flask.cli import with_appcontext from .extensions import db -from .models import User +from .models import AppSettings, User @click.command("init-db") @@ -36,10 +36,20 @@ def init_db_command(admin_email: str, admin_pass: str): if "storage_max_bytes" not in company_cols: db.session.execute(db.text("ALTER TABLE company ADD COLUMN storage_max_bytes BIGINT")) db.session.commit() + + settings_cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(app_settings)")).fetchall()] + if settings_cols and "public_domain" not in settings_cols: + db.session.execute(db.text("ALTER TABLE app_settings ADD COLUMN public_domain VARCHAR(255)")) + db.session.commit() except Exception: # Best-effort; if it fails we continue so fresh DBs still work. db.session.rollback() + # Ensure AppSettings row exists. + if not db.session.get(AppSettings, 1): + db.session.add(AppSettings(id=1)) + db.session.commit() + admin_email = (admin_email or "").strip().lower() if not admin_email: raise click.UsageError("--admin-email is required") diff --git a/app/email_utils.py b/app/email_utils.py index 077da98..6266d84 100644 --- a/app/email_utils.py +++ b/app/email_utils.py @@ -3,8 +3,17 @@ import smtplib from email.message import EmailMessage +def _truthy(v: str | None) -> bool: + if v is None: + return False + return v.strip().lower() in ("1", "true", "yes", "on") + + def send_email(*, to_email: str, subject: str, body_text: str): - """Send a plain-text email using SMTP settings from environment variables. + """Send a plain-text email using SMTP settings from: + + 1) Environment variables (highest priority) + 2) Admin-configured settings stored in the database (AppSettings) Required env vars: - SMTP_HOST @@ -19,14 +28,46 @@ def send_email(*, to_email: str, subject: str, body_text: str): - SMTP_DEBUG (default: "0") - set to 1 to print SMTP conversation to console """ - host = os.environ.get("SMTP_HOST") - port = int(os.environ.get("SMTP_PORT", "587")) - username = os.environ.get("SMTP_USERNAME") - password = os.environ.get("SMTP_PASSWORD") - from_email = os.environ.get("SMTP_FROM") or username - starttls = os.environ.get("SMTP_STARTTLS", "1").lower() in ("1", "true", "yes", "on") - timeout = float(os.environ.get("SMTP_TIMEOUT_SECONDS", "10")) - debug = os.environ.get("SMTP_DEBUG", "0").lower() in ("1", "true", "yes", "on") + # Pull optional defaults from DB (if available). This keeps backwards compatibility: + # existing deployments that only use env vars keep working unchanged. + db_defaults = {} + try: + # Local import to avoid import cycles and to keep this module lightweight. + from flask import current_app + + from .extensions import db + from .models import AppSettings + + # Only try if app context exists (send_email is called inside requests/CLI normally). + _ = current_app # noqa: F841 + s = db.session.get(AppSettings, 1) + if s: + db_defaults = { + "host": s.smtp_host, + "port": s.smtp_port, + "username": s.smtp_username, + "password": s.smtp_password, + "from_email": s.smtp_from, + "starttls": s.smtp_starttls, + "timeout": s.smtp_timeout_seconds, + "debug": s.smtp_debug, + } + except Exception: + # Best-effort; if DB isn't ready we fall back to env vars only. + db_defaults = {} + + host = os.environ.get("SMTP_HOST") or db_defaults.get("host") + port = int(os.environ.get("SMTP_PORT") or db_defaults.get("port") or 587) + username = os.environ.get("SMTP_USERNAME") or db_defaults.get("username") + password = os.environ.get("SMTP_PASSWORD") or db_defaults.get("password") + from_email = os.environ.get("SMTP_FROM") or db_defaults.get("from_email") or username + starttls = _truthy(os.environ.get("SMTP_STARTTLS")) if os.environ.get("SMTP_STARTTLS") is not None else bool( + db_defaults.get("starttls") if db_defaults.get("starttls") is not None else True + ) + timeout = float(os.environ.get("SMTP_TIMEOUT_SECONDS") or db_defaults.get("timeout") or 10) + debug = _truthy(os.environ.get("SMTP_DEBUG")) if os.environ.get("SMTP_DEBUG") is not None else bool( + db_defaults.get("debug") or False + ) missing = [] if not host: @@ -41,13 +82,17 @@ def send_email(*, to_email: str, subject: str, body_text: str): raise RuntimeError( "Missing SMTP configuration: " + ", ".join(missing) - + ". Set them as environment variables (or in a local .env file)." + + ". Set them as environment variables (or configure them in Admin → Settings)." ) msg = EmailMessage() msg["From"] = from_email msg["To"] = to_email msg["Subject"] = subject + # Helps when SMTP providers force the authenticated mailbox as envelope sender, + # but still allow replies to go to the desired address. + if from_email and username and from_email != username: + msg["Reply-To"] = from_email msg.set_content(body_text) with smtplib.SMTP(host, port, timeout=timeout) as smtp: @@ -58,4 +103,7 @@ def send_email(*, to_email: str, subject: str, body_text: str): smtp.starttls() smtp.ehlo() smtp.login(username, password) - smtp.send_message(msg) + + # Pass explicit envelope-from to avoid falling back to the authenticated user. + # Note: some SMTP providers will still override this for anti-spoofing. + smtp.send_message(msg, from_addr=from_email, to_addrs=[to_email]) diff --git a/app/models.py b/app/models.py index 7dfaa1a..851d46c 100644 --- a/app/models.py +++ b/app/models.py @@ -115,3 +115,38 @@ class DisplaySession(db.Model): display = db.relationship("Display") __table_args__ = (db.UniqueConstraint("display_id", "sid", name="uq_display_session_display_sid"),) + + +class AppSettings(db.Model): + """Singleton-ish app-wide settings. + + For this small project we avoid Alembic migrations; this table can be created via + `flask init-db` (db.create_all) and is also created best-effort on app startup. + + NOTE: SMTP password is stored in plaintext in the database. + Prefer environment variables / secrets management in production when possible. + """ + + id = db.Column(db.Integer, primary_key=True) + + smtp_host = db.Column(db.String(255), nullable=True) + smtp_port = db.Column(db.Integer, nullable=True) + smtp_username = db.Column(db.String(255), nullable=True) + smtp_password = db.Column(db.String(255), nullable=True) + smtp_from = db.Column(db.String(255), nullable=True) + + smtp_starttls = db.Column(db.Boolean, default=True, nullable=False) + smtp_timeout_seconds = db.Column(db.Float, default=10.0, nullable=False) + smtp_debug = db.Column(db.Boolean, default=False, nullable=False) + + # Public domain for generating absolute links in emails. + # Example: "signage.example.com" (no scheme) + public_domain = db.Column(db.String(255), nullable=True) + + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column( + db.DateTime, + default=datetime.utcnow, + onupdate=datetime.utcnow, + nullable=False, + ) diff --git a/app/routes/admin.py b/app/routes/admin.py index 0c3f011..561a6a9 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -7,7 +7,8 @@ from flask_login import current_user, login_required, login_user from ..extensions import db from ..uploads import abs_upload_path, ensure_company_upload_dir, get_company_upload_bytes, is_valid_upload_relpath -from ..models import Company, Display, DisplaySession, Playlist, PlaylistItem, User +from ..models import AppSettings, Company, Display, DisplaySession, Playlist, PlaylistItem, User +from ..email_utils import send_email bp = Blueprint("admin", __name__, url_prefix="/admin") @@ -17,6 +18,18 @@ def admin_required(): abort(403) +def _get_app_settings() -> AppSettings: + """Get the singleton-ish AppSettings row, creating it if needed.""" + + s = db.session.get(AppSettings, 1) + if s: + return s + s = AppSettings(id=1) + db.session.add(s) + db.session.commit() + return s + + def _try_delete_upload(file_path: str | None, upload_folder: str): """Best-effort delete of an uploaded media file.""" if not file_path: @@ -43,6 +56,188 @@ def dashboard(): return render_template("admin/dashboard.html", companies=companies) +@bp.get("/settings") +@login_required +def settings(): + admin_required() + settings = _get_app_settings() + admins = User.query.filter_by(is_admin=True).order_by(User.email.asc()).all() + return render_template("admin/settings.html", settings=settings, admins=admins) + + +@bp.post("/settings/smtp") +@login_required +def update_smtp_settings(): + admin_required() + s = _get_app_settings() + + smtp_host = (request.form.get("smtp_host") or "").strip() or None + smtp_port_raw = (request.form.get("smtp_port") or "").strip() + smtp_username = (request.form.get("smtp_username") or "").strip() or None + smtp_password = request.form.get("smtp_password") + smtp_from = (request.form.get("smtp_from") or "").strip() or None + smtp_starttls = (request.form.get("smtp_starttls") or "").lower() in ("1", "true", "yes", "on") + smtp_debug = (request.form.get("smtp_debug") or "").lower() in ("1", "true", "yes", "on") + smtp_timeout_raw = (request.form.get("smtp_timeout_seconds") or "").strip() + + smtp_port: int | None = None + if smtp_port_raw: + try: + smtp_port = int(smtp_port_raw) + except ValueError: + flash("SMTP port must be a number", "danger") + return redirect(url_for("admin.settings")) + + smtp_timeout: float = 10.0 + if smtp_timeout_raw: + try: + smtp_timeout = float(smtp_timeout_raw) + except ValueError: + flash("SMTP timeout must be a number (seconds)", "danger") + return redirect(url_for("admin.settings")) + + if smtp_port is not None and (smtp_port <= 0 or smtp_port > 65535): + flash("SMTP port must be between 1 and 65535", "danger") + return redirect(url_for("admin.settings")) + + if smtp_timeout <= 0: + flash("SMTP timeout must be > 0", "danger") + return redirect(url_for("admin.settings")) + + s.smtp_host = smtp_host + s.smtp_port = smtp_port + s.smtp_username = smtp_username + + # Only overwrite password if a value was submitted. + # This allows editing other SMTP fields without having to re-enter the password. + if smtp_password is not None and smtp_password.strip() != "": + s.smtp_password = smtp_password + + s.smtp_from = smtp_from + s.smtp_starttls = smtp_starttls + s.smtp_timeout_seconds = smtp_timeout + s.smtp_debug = smtp_debug + db.session.commit() + + flash("SMTP settings saved.", "success") + return redirect(url_for("admin.settings")) + + +@bp.post("/settings/domain") +@login_required +def update_public_domain(): + admin_required() + s = _get_app_settings() + raw = (request.form.get("public_domain") or "").strip().lower() + if raw: + # Normalize: user asked for domain-only (no scheme). Strip possible scheme anyway. + raw = raw.replace("http://", "").replace("https://", "") + raw = raw.strip().strip("/") + if "/" in raw: + flash("Public domain must not contain a path. Example: signage.example.com", "danger") + return redirect(url_for("admin.settings")) + if " " in raw: + flash("Public domain must not contain spaces", "danger") + return redirect(url_for("admin.settings")) + s.public_domain = raw + else: + s.public_domain = None + db.session.commit() + flash("Public domain saved.", "success") + return redirect(url_for("admin.settings")) + + +@bp.post("/settings/test-email") +@login_required +def send_test_email(): + admin_required() + to_email = (request.form.get("to_email") or "").strip().lower() + if not to_email: + flash("Test email recipient is required", "danger") + return redirect(url_for("admin.settings")) + + try: + send_email( + to_email=to_email, + subject="Signage SMTP test", + body_text="This is a test email from Signage (Admin → Settings).", + ) + except Exception as e: + # Show a short error to the admin (they requested a test). + flash(f"Failed to send test email: {e}", "danger") + return redirect(url_for("admin.settings")) + + flash(f"Test email sent to {to_email}", "success") + return redirect(url_for("admin.settings")) + + +@bp.post("/settings/admins") +@login_required +def create_admin_user(): + admin_required() + email = (request.form.get("email") or "").strip().lower() + password = request.form.get("password") or "" + + if not email or not password: + flash("Email and password are required", "danger") + return redirect(url_for("admin.settings")) + + if len(password) < 8: + flash("Password must be at least 8 characters", "danger") + return redirect(url_for("admin.settings")) + + existing = User.query.filter_by(email=email).first() + if existing: + if existing.is_admin: + flash("That user is already an admin", "warning") + return redirect(url_for("admin.settings")) + # Promote existing user to admin + existing.is_admin = True + existing.set_password(password) + existing.email = email + existing.username = email + existing.company_id = None + db.session.commit() + flash("User promoted to admin.", "success") + return redirect(url_for("admin.settings")) + + u = User(is_admin=True) + u.email = email + u.username = email + u.set_password(password) + db.session.add(u) + db.session.commit() + flash("Admin user created.", "success") + return redirect(url_for("admin.settings")) + + +@bp.post("/settings/admins//demote") +@login_required +def demote_admin_user(user_id: int): + admin_required() + if current_user.id == user_id: + flash("You cannot demote yourself", "danger") + return redirect(url_for("admin.settings")) + + u = db.session.get(User, user_id) + if not u: + abort(404) + if not u.is_admin: + flash("That user is not an admin", "warning") + return redirect(url_for("admin.settings")) + + # Ensure we always keep at least one admin. + admin_count = User.query.filter_by(is_admin=True).count() + if admin_count <= 1: + flash("Cannot demote the last remaining admin", "danger") + return redirect(url_for("admin.settings")) + + u.is_admin = False + db.session.commit() + flash("Admin user demoted.", "success") + return redirect(url_for("admin.settings")) + + @bp.post("/companies") @login_required def create_company(): diff --git a/app/routes/auth.py b/app/routes/auth.py index 0d68a75..9556985 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -7,7 +7,7 @@ from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer from ..extensions import db from ..email_utils import send_email -from ..models import User +from ..models import AppSettings, User from ..auth_tokens import load_password_reset_user_id, make_password_reset_token bp = Blueprint("auth", __name__, url_prefix="/auth") @@ -48,7 +48,18 @@ def forgot_password_post(): user = User.query.filter_by(email=email).first() if user: token = _make_reset_token(user) - reset_url = url_for("auth.reset_password", token=token, _external=True) + + # By default Flask uses the request host when building _external URLs. + # For deployments behind proxies or where the public host differs, allow + # admins to configure a public domain used in email links. + settings = db.session.get(AppSettings, 1) + if settings and settings.public_domain: + # Flask's url_for doesn't support overriding the host per-call. + # We generate the relative path and prefix it with the configured domain. + path = url_for("auth.reset_password", token=token, _external=False) + reset_url = f"https://{settings.public_domain}{path}" + else: + reset_url = url_for("auth.reset_password", token=token, _external=True) body = ( "Someone requested a password reset for your account.\n\n" f"Reset your password using this link (valid for 30 minutes):\n{reset_url}\n\n" diff --git a/app/routes/company.py b/app/routes/company.py index fd63d89..220e849 100644 --- a/app/routes/company.py +++ b/app/routes/company.py @@ -18,7 +18,7 @@ from ..uploads import ( get_company_upload_bytes, is_valid_upload_relpath, ) -from ..models import Company, Display, DisplaySession, Playlist, PlaylistItem, User +from ..models import AppSettings, Company, Display, DisplaySession, Playlist, PlaylistItem, User from ..email_utils import send_email from ..auth_tokens import make_password_reset_token @@ -289,7 +289,13 @@ def invite_user(): db.session.commit() token = make_password_reset_token(secret_key=current_app.config["SECRET_KEY"], user_id=u.id) - reset_url = url_for("auth.reset_password", token=token, _external=True) + + settings = db.session.get(AppSettings, 1) + if settings and settings.public_domain: + path = url_for("auth.reset_password", token=token, _external=False) + reset_url = f"https://{settings.public_domain}{path}" + else: + reset_url = url_for("auth.reset_password", token=token, _external=True) body = ( f"You have been invited to {company.name} on Signage.\n\n" "Set your password using this link (valid for 30 minutes):\n" diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html index aede741..bdf1166 100644 --- a/app/templates/admin/dashboard.html +++ b/app/templates/admin/dashboard.html @@ -2,6 +2,7 @@ {% block content %}

Admin

+ Settings
diff --git a/app/templates/admin/settings.html b/app/templates/admin/settings.html new file mode 100644 index 0000000..1b04d93 --- /dev/null +++ b/app/templates/admin/settings.html @@ -0,0 +1,205 @@ +{% extends "base.html" %} +{% block content %} +
+

Admin settings

+ Back +
+ +
+
+
+
+

SMTP settings

+
+
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ For security, the current password is never shown. Leave empty to keep it unchanged. +
+
+ +
+
+ + +
+
+
+ + +
+
+
+ +
+ + +
Prints SMTP conversation to the Flask console (useful for troubleshooting).
+
+ +
+ +
+
+
+
+ +
+
+

Send test email

+
+
+
+
+ + +
Sends a short test email using the current SMTP configuration.
+
+
+ +
+
+
+
+ +
+
+

Public domain

+
+
+
+
+ + +
+ Used to generate absolute links (like password reset). Do not include http(s)://. + Leave empty to use the current request host. +
+
+
+ +
+
+
+
+
+ +
+
+
+

Admin users

+
+
+
+
+
+ +
+
+ +
+
+
+ +
+
+ +
+ + + + + + + + + {% for u in admins %} + + + + + {% else %} + + + + {% endfor %} + +
EmailAction
+ {{ u.email }} + {% if u.id == current_user.id %} + You + {% endif %} + + {% if u.id != current_user.id %} +
+ +
+ {% else %} + (cannot demote yourself) + {% endif %} +
No admin users found.
+
+ +
+ Safety: the last remaining admin cannot be demoted. +
+
+
+
+
+{% endblock %} diff --git a/scripts/smoke_test.py b/scripts/smoke_test.py index 3b7cc14..b88d617 100644 --- a/scripts/smoke_test.py +++ b/scripts/smoke_test.py @@ -21,6 +21,7 @@ def main(): required = { "/admin/companies//delete", "/admin/displays//name", + "/admin/settings", "/company/displays/", "/company/items//duration", "/company/playlists//items/reorder",