From fe8e8c7e64d3d5c3faefb13de03abd4133b611c7 Mon Sep 17 00:00:00 2001 From: Blake Ridgway Date: Thu, 3 Apr 2025 11:49:40 -0500 Subject: [PATCH 1/8] (style): Refactor HTML templates and add CSS Refactored HTML templates for improved strucutre and clarity Moved styles to a separate CSS file for better maintainablilty --- static/css/style.css | 55 ++++++++++++++++++++++++++++++++++++++ templates/admin_index.html | 45 +++++++++++++++---------------- templates/login.html | 28 +++++++++---------- templates/send_update.html | 29 +++++++------------- 4 files changed, 99 insertions(+), 58 deletions(-) create mode 100644 static/css/style.css diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..b9c3f88 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,55 @@ +body { + font-family: Arial, sans-serif; + padding: 20px; + } + + table { + border-collapse: collapse; + width: 100%; + } + + th, + td { + border: 1px solid #ddd; + padding: 8px; + text-align: left; + } + + th { + background-color: #f2f2f2; + } + + a { + margin-right: 10px; + } + + form { + max-width: 600px; + margin: 0 auto; + } + + label { + display: block; + margin-top: 15px; + } + + input[type="text"], + input[type="password"], + textarea { + width: 100%; + padding: 8px; + } + + button { + margin-top: 15px; + padding: 10px 20px; + } + + .flash { + background-color: #f8d7da; + color: #721c24; + padding: 10px; + margin-bottom: 10px; + text-align: center; + } + \ No newline at end of file diff --git a/templates/admin_index.html b/templates/admin_index.html index a03b4e3..83ded8e 100644 --- a/templates/admin_index.html +++ b/templates/admin_index.html @@ -4,30 +4,29 @@ Admin Center - Subscribers - +

Subscribers

-

Send Update Email

- {% if emails %} - - - - - {% for email in emails %} - - - - {% endfor %} -
Email Address
{{ email }}
- {% else %} -

No subscribers found.

- {% endif %} +

Send Update Email

+ + {% if emails %} + + + + + + + + {% for email in emails %} + + + + {% endfor %} + +
Email Address
{{ email }}
+ {% else %} +

No subscribers found.

+ {% endif %} - \ No newline at end of file + diff --git a/templates/login.html b/templates/login.html index d110448..97fefdd 100644 --- a/templates/login.html +++ b/templates/login.html @@ -1,26 +1,21 @@ + - - + + Admin Login - + +

Admin Login

{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
{{ message }}
- {% endfor %} - {% endif %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} {% endwith %}
@@ -30,4 +25,5 @@
- \ No newline at end of file + + diff --git a/templates/send_update.html b/templates/send_update.html index a6d24b6..0df7078 100644 --- a/templates/send_update.html +++ b/templates/send_update.html @@ -1,31 +1,21 @@ + - + Admin Center - Send Update - + +

Send Update Email

{% with messages = get_flashed_messages() %} - {% if messages %} - {% for message in messages %} -
{{ message }}
- {% endfor %} - {% endif %} + {% if messages %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} {% endwith %}
@@ -42,4 +32,5 @@ Logout

+ From 145d426dc019690af24b7e815c751a2470e3338b Mon Sep 17 00:00:00 2001 From: Blake Ridgway Date: Thu, 3 Apr 2025 11:50:18 -0500 Subject: [PATCH 2/8] (refactor): Improve email sending and logging, enhance security Implemented robust logging using the logging module Improved error handling and resource management with try...except..finally blocks Seprareted email sending logic into smaller managable functions Added SENDER_EMAIL configuration for sending emails Fixed security vulnerabilities --- app.py | 128 ++++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 82 insertions(+), 46 deletions(-) diff --git a/app.py b/app.py index 183e075..e719f79 100644 --- a/app.py +++ b/app.py @@ -1,103 +1,137 @@ import os +import logging import smtplib from email.mime.text import MIMEText -from flask import Flask, render_template, request, redirect, url_for, flash, session +from flask import ( + Flask, + render_template, + request, + redirect, + url_for, + flash, + session, +) from dotenv import load_dotenv from werkzeug.security import check_password_hash +from functools import wraps # Import wraps from database import get_connection, init_db, get_all_emails, get_admin, create_default_admin load_dotenv() app = Flask(__name__) -# Use a secret key from .env; ensure your .env sets SECRET_KEY -app.secret_key = os.getenv('SECRET_KEY') -base_url = os.getenv('BASE_URL') +app.secret_key = os.getenv("SECRET_KEY") +base_url = os.getenv("BASE_URL") # SMTP settings (for sending update emails) -SMTP_SERVER = os.getenv('SMTP_SERVER') +SMTP_SERVER = os.getenv("SMTP_SERVER") SMTP_PORT = int(os.getenv("SMTP_PORT", 465)) -SMTP_USER = os.getenv('SMTP_USER') -SMTP_PASSWORD = os.getenv('SMTP_PASSWORD') +SMTP_USER = os.getenv("SMTP_USER") +SMTP_PASSWORD = os.getenv("SMTP_PASSWORD") +SENDER_EMAIL = os.getenv("SENDER_EMAIL", SMTP_USER) # Use SENDER_EMAIL + +# Logging setup +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) # Initialize the database and create default admin user if necessary. init_db() create_default_admin() +# Decorator for requiring login def login_required(f): - from functools import wraps - @wraps(f) + @wraps(f) # Use wraps to preserve function metadata def decorated_function(*args, **kwargs): if "username" not in session: - return redirect(url_for('login')) + return redirect(url_for("login")) return f(*args, **kwargs) + return decorated_function + +def send_update_email(subject, body, email): + """Sends email, returns True on success, False on failure.""" + try: + server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) + server.set_debuglevel(False) # Keep debug level at False for production + server.login(SMTP_USER, SMTP_PASSWORD) + + unsub_link = f"https://{base_url}/unsubscribe?email={email}" + custom_body = ( + f"{body}

" + f"If you ever wish to unsubscribe, please click here" + ) + + msg = MIMEText(custom_body, "html", "utf-8") + msg["Subject"] = subject + msg["From"] = SENDER_EMAIL # Use sender email + msg["To"] = email + + server.sendmail(SENDER_EMAIL, email, msg.as_string()) # Use sender email + + server.quit() + logger.info(f"Update email sent to: {email}") + return True + except Exception as e: + logger.error(f"Failed to send email to {email}: {e}") + return False + + def process_send_update_email(subject, body): """Helper function to send an update email to all subscribers.""" subscribers = get_all_emails() if not subscribers: return "No subscribers found." try: - server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) - server.set_debuglevel(True) - server.login(SMTP_USER, SMTP_PASSWORD) for email in subscribers: - unsub_link = f"https://{base_url}/unsubscribe?email={email}" - custom_body = ( - f"{body}

" - f"If you ever wish to unsubscribe, please click here" - ) - msg = MIMEText(custom_body, 'html', 'utf-8') - msg['Subject'] = subject - msg['From'] = SMTP_USER - msg['To'] = email - server.sendmail(SMTP_USER, email, msg.as_string()) - print(f"Update email sent to: {email}") - server.quit() - + if not send_update_email(subject, body, email): + return f"Failed to send to {email}" # Specific failure message + + # Log newsletter content for audit purposes conn = get_connection() cursor = conn.cursor() cursor.execute( - "INSERT INTO newsletters (subject, body) VALUES (%s, %s)", - (subject, body) - ) + "INSERT INTO newsletters (subject, body) VALUES (%s, %s)", (subject, body) + ) conn.commit() cursor.close() conn.close() - return "Email has been sent." + return "Email has been sent to all subscribers." except Exception as e: - print(f"Failed to send email: {e}") + logger.exception("Error processing sending updates") return f"Failed to send email: {e}" -@app.route('/') + +@app.route("/") @login_required def index(): """Displays all subscriber emails""" emails = get_all_emails() return render_template("admin_index.html", emails=emails) -@app.route('/send_update', methods=['GET', 'POST']) + +@app.route("/send_update", methods=["GET", "POST"]) @login_required def send_update(): """Display a form to send an update email; process submission on POST.""" - if request.method == 'POST': - subject = request.form['subject'] - body = request.form['body'] - # Call the helper function using its new name. + if request.method == "POST": + subject = request.form["subject"] + body = request.form["body"] result_message = process_send_update_email(subject, body) flash(result_message) return redirect(url_for("send_update")) return render_template("send_update.html") -@app.route('/login', methods=['GET', 'POST']) + +@app.route("/login", methods=["GET", "POST"]) def login(): - if request.method == 'POST': - username = request.form.get('username') - password = request.form.get('password') + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") admin = get_admin(username) - # Expect get_admin() to return a tuple like (username, password_hash) if admin and check_password_hash(admin[1], password): - session['username'] = username + session["username"] = username flash("Logged in successfully", "success") return redirect(url_for("index")) else: @@ -105,11 +139,13 @@ def login(): return redirect(url_for("login")) return render_template("login.html") -@app.route('/logout') + +@app.route("/logout") def logout(): - session.pop('username', None) + session.pop("username", None) flash("Logged out successfully", "success") return redirect(url_for("login")) -if __name__ == '__main__': + +if __name__ == "__main__": app.run(port=5001, debug=True) From 10fe98b5ca276e3bd4af71726d684d6e9ddba973 Mon Sep 17 00:00:00 2001 From: Blake Ridgway Date: Thu, 3 Apr 2025 11:51:54 -0500 Subject: [PATCH 3/8] (refactor): Enhance database connection and initialization with error handling and logging Implemented robust logging using the logging module Improved database connection with try...except...finally blocks for better error handling and resource management Enhanced database initialization with proper error handling and logging --- database.py | 182 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 131 insertions(+), 51 deletions(-) diff --git a/database.py b/database.py index eb88691..b90e0ef 100644 --- a/database.py +++ b/database.py @@ -1,43 +1,89 @@ import os +import logging import psycopg2 from psycopg2 import IntegrityError from dotenv import load_dotenv from werkzeug.security import generate_password_hash + load_dotenv() +# Logging setup +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + def get_connection(): """Return a new connection to the PostgreSQL database.""" - return psycopg2.connect( - host=os.getenv("PG_HOST"), - port=os.getenv("PG_PORT"), - dbname=os.getenv("PG_DATABASE"), - user=os.getenv("PG_USER"), - password=os.getenv("PG_PASSWORD"), - connect_timeout=10 - ) + try: + conn = psycopg2.connect( + host=os.getenv("PG_HOST"), + port=os.getenv("PG_PORT"), + dbname=os.getenv("PG_DATABASE"), + user=os.getenv("PG_USER"), + password=os.getenv("PG_PASSWORD"), + connect_timeout=10, + ) + return conn + except Exception as e: + logger.error(f"Database connection error: {e}") + raise + def init_db(): """Initialize the database tables.""" - conn = get_connection() - cursor = conn.cursor() - # Create subscribers table (if not exists) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS subscribers ( - id SERIAL PRIMARY KEY, - email TEXT UNIQUE NOT NULL + conn = None + try: + conn = get_connection() + cursor = conn.cursor() + + # Create subscribers table (if not exists) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS subscribers ( + id SERIAL PRIMARY KEY, + email TEXT UNIQUE NOT NULL + ) + """ ) - """) - # Create admin_users table (if not exists) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS admin_users ( - id SERIAL PRIMARY KEY, - username TEXT UNIQUE NOT NULL, - password TEXT NOT NULL + + # Create admin_users table (if not exists) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS admin_users ( + id SERIAL PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL + ) + """ ) - """) - conn.commit() - cursor.close() - conn.close() + + # Newsletter storage + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS newsletters ( + id SERIAL PRIMARY KEY, + subject TEXT NOT NULL, + body TEXT NOT NULL, + sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + + conn.commit() + logger.info("Database initialized successfully.") + except Exception as e: + logger.error(f"Database initialization error: {e}") + if conn: + conn.rollback() # Rollback if there's an error + + raise + finally: + if conn: + cursor.close() + conn.close() + def get_all_emails(): """Return a list of all subscriber emails.""" @@ -46,78 +92,112 @@ def get_all_emails(): cursor = conn.cursor() cursor.execute("SELECT email FROM subscribers") results = cursor.fetchall() - cursor.close() - conn.close() - return [row[0] for row in results] + emails = [row[0] for row in results] + logger.debug(f"Retrieved emails: {emails}") + return emails except Exception as e: - print(f"Error retrieving emails: {e}") + logger.error(f"Error retrieving emails: {e}") return [] + finally: + if conn: + cursor.close() + conn.close() + def add_email(email): """Insert an email into the subscribers table.""" + conn = None try: conn = get_connection() cursor = conn.cursor() cursor.execute("INSERT INTO subscribers (email) VALUES (%s)", (email,)) conn.commit() - cursor.close() - conn.close() + logger.info(f"Email {email} added successfully.") return True except IntegrityError: + logger.warning(f"Attempted to add duplicate email: {email}") return False except Exception as e: - print(f"Error adding email: {e}") + logger.error(f"Error adding email {email}: {e}") return False + finally: + if conn: + cursor.close() + conn.close() + def remove_email(email): """Remove an email from the subscribers table.""" + conn = None try: conn = get_connection() cursor = conn.cursor() cursor.execute("DELETE FROM subscribers WHERE email = %s", (email,)) - conn.commit() rowcount = cursor.rowcount - cursor.close() - conn.close() + conn.commit() + logger.info(f"Email {email} removed successfully.") return rowcount > 0 except Exception as e: - print(f"Error removing email: {e}") + logger.error(f"Error removing email {email}: {e}") return False + finally: + if conn: + cursor.close() + conn.close() + def get_admin(username): """Retrieve admin credentials for a given username. - Returns a tuple (username, password_hash) if found, otherwise None. + Returns a tuple (username, password_hash) if found, otherwise None. """ + conn = None try: conn = get_connection() cursor = conn.cursor() - cursor.execute("SELECT username, password FROM admin_users WHERE username = %s", (username,)) + cursor.execute( + "SELECT username, password FROM admin_users WHERE username = %s", + (username,), + ) result = cursor.fetchone() - cursor.close() - conn.close() return result # (username, password_hash) except Exception as e: - print(f"Error retrieving admin: {e}") + logger.error(f"Error retrieving admin: {e}") return None + finally: + if conn: + cursor.close() + conn.close() + def create_default_admin(): """Create a default admin user if one doesn't already exist.""" default_username = os.getenv("ADMIN_USERNAME", "admin") default_password = os.getenv("ADMIN_PASSWORD", "changeme") - hashed = generate_password_hash(default_password, method="pbkdf2:sha256") + hashed_password = generate_password_hash(default_password, method="pbkdf2:sha256") + conn = None try: conn = get_connection() cursor = conn.cursor() + # Check if the admin already exists - cursor.execute("SELECT id FROM admin_users WHERE username = %s", (default_username,)) + cursor.execute( + "SELECT id FROM admin_users WHERE username = %s", (default_username,) + ) if cursor.fetchone() is None: - cursor.execute("INSERT INTO admin_users (username, password) VALUES (%s, %s)", - (default_username, hashed)) + cursor.execute( + "INSERT INTO admin_users (username, password) VALUES (%s, %s)", + (default_username, hashed_password), + ) conn.commit() - print("Default admin created successfully") + logger.info("Default admin created successfully") else: - print("Default admin already exists") - cursor.close() - conn.close() + logger.info("Default admin already exists") except Exception as e: - print(f"Error creating default admin: {e}") + logger.error(f"Error creating default admin: {e}") + if conn: + conn.rollback() + finally: + if conn: + cursor.close() + conn.close() + From 9784c925031deb5bc22febf202edabc5e68dcf2b Mon Sep 17 00:00:00 2001 From: Blake Ridgway Date: Thu, 3 Apr 2025 11:52:56 -0500 Subject: [PATCH 4/8] (build): Add static folder Modify Dockerfile to copy the static folder --- Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index d93dc05..5d0ac86 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,4 +16,3 @@ ENV FLASK_APP=server.py EXPOSE 5001 CMD ["gunicorn", "--bind", "0.0.0.0:5001", "app:app"] - From fd199c30dc3f8a352346fbe1569bf34f1d793dda Mon Sep 17 00:00:00 2001 From: Blake Ridgway Date: Thu, 3 Apr 2025 13:20:59 -0500 Subject: [PATCH 5/8] (refactor): Simple deleted empty line --- app.py | 1 - 1 file changed, 1 deletion(-) diff --git a/app.py b/app.py index e719f79..8d6188e 100644 --- a/app.py +++ b/app.py @@ -110,7 +110,6 @@ def index(): emails = get_all_emails() return render_template("admin_index.html", emails=emails) - @app.route("/send_update", methods=["GET", "POST"]) @login_required def send_update(): From abd2bef10451b31c17d2a48b2b51d57821e464f7 Mon Sep 17 00:00:00 2001 From: Blake Ridgway Date: Thu, 3 Apr 2025 13:21:52 -0500 Subject: [PATCH 6/8] (style): Adjust order to fix formatting of flash messages Adjusted order in send_update.html to fix formatting for flash messages. --- templates/send_update.html | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/templates/send_update.html b/templates/send_update.html index 0df7078..a7c093c 100644 --- a/templates/send_update.html +++ b/templates/send_update.html @@ -10,6 +10,10 @@

Send Update Email

+

+ Back to Subscribers List | + Logout +

{% with messages = get_flashed_messages() %} {% if messages %} {% for message in messages %} @@ -27,10 +31,7 @@ -

- Back to Subscribers List | - Logout -

+ From 85c9ab1364186f409b48cf6dc65d9edc82f566b1 Mon Sep 17 00:00:00 2001 From: Blake Ridgway Date: Thu, 3 Apr 2025 13:22:13 -0500 Subject: [PATCH 7/8] (style): Adjust order to fix formatting of flash messages Adjusted order in admin_index.html to fix formatting for flash messages. --- templates/admin_index.html | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/templates/admin_index.html b/templates/admin_index.html index 83ded8e..f85a971 100644 --- a/templates/admin_index.html +++ b/templates/admin_index.html @@ -7,8 +7,20 @@ +

Subscribers

-

Send Update Email

+

+ Send Update Email| + Logout +

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} {% if emails %} From 7a88a01e0be656586f5faf9e89f2dbd7444415af Mon Sep 17 00:00:00 2001 From: Blake Ridgway Date: Thu, 3 Apr 2025 13:24:51 -0500 Subject: [PATCH 8/8] (refactor): Fixed format of the with/if blocks --- templates/admin_index.html | 10 +++++----- templates/send_update.html | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/templates/admin_index.html b/templates/admin_index.html index f85a971..c4620af 100644 --- a/templates/admin_index.html +++ b/templates/admin_index.html @@ -15,11 +15,11 @@

{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
{{ message }}
- {% endfor %} - {% endif %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} {% endwith %} {% if emails %} diff --git a/templates/send_update.html b/templates/send_update.html index a7c093c..fccd7ff 100644 --- a/templates/send_update.html +++ b/templates/send_update.html @@ -15,11 +15,11 @@ Logout

{% with messages = get_flashed_messages() %} - {% if messages %} - {% for message in messages %} -
{{ message }}
- {% endfor %} - {% endif %} + {% if messages %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} {% endwith %}