v_1.2
This commit is contained in:
@@ -0,0 +1,724 @@
|
||||
"""
|
||||
Birthday Manager - self-hosted birthday reminder & e-mail application
|
||||
(c) 2026 Martinhal IT - Joao Vaz - Version 1.2
|
||||
"""
|
||||
import base64
|
||||
import calendar as cal
|
||||
import imaplib
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import smtplib
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from email.mime.image import MIMEImage
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from functools import wraps
|
||||
|
||||
import pyotp
|
||||
import qrcode
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from flask import (Flask, flash, g, redirect, render_template, request,
|
||||
send_from_directory, session, url_for)
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DATA_DIR = os.environ.get("DATA_DIR", os.path.join(BASE_DIR, "data"))
|
||||
UPLOAD_DIR = os.path.join(DATA_DIR, "uploads")
|
||||
DB_PATH = os.path.join(DATA_DIR, "birthday.db")
|
||||
ALLOWED_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
|
||||
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.environ.get("SECRET_KEY", secrets.token_hex(32))
|
||||
app.config["MAX_CONTENT_LENGTH"] = 15 * 1024 * 1024 # 15 MB total upload limit
|
||||
|
||||
APP_VERSION = "Version 1.2"
|
||||
FOOTER_TEXT = "\u00a9 2026 Martinhal IT - Joao Vaz - " + APP_VERSION
|
||||
|
||||
# ---------------------------------------------------------------- database
|
||||
|
||||
def get_db():
|
||||
if "db" not in g:
|
||||
g.db = sqlite3.connect(DB_PATH)
|
||||
g.db.row_factory = sqlite3.Row
|
||||
g.db.execute("PRAGMA foreign_keys = ON")
|
||||
return g.db
|
||||
|
||||
|
||||
@app.teardown_appcontext
|
||||
def close_db(_exc):
|
||||
db = g.pop("db", None)
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
|
||||
def db_connect():
|
||||
"""Standalone connection for use outside a request (scheduler)."""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
DEFAULT_TEMPLATE_MALE = (
|
||||
"<p>Dear {first_name} {last_name},</p>"
|
||||
"<p>Happy Birthday! Wishing you a fantastic day and a wonderful year ahead. "
|
||||
"Congratulations on turning {age}!</p>"
|
||||
"<p>Best regards,<br>Martinhal IT</p>"
|
||||
)
|
||||
DEFAULT_TEMPLATE_FEMALE = (
|
||||
"<p>Dear {first_name} {last_name},</p>"
|
||||
"<p>Happy Birthday! Wishing you a beautiful day filled with joy. "
|
||||
"Congratulations on turning {age}!</p>"
|
||||
"<p>Warm regards,<br>Martinhal IT</p>"
|
||||
)
|
||||
|
||||
|
||||
def init_db():
|
||||
conn = db_connect()
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
mfa_secret TEXT,
|
||||
mfa_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS birthdays (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
dob TEXT NOT NULL, -- YYYY-MM-DD
|
||||
gender TEXT NOT NULL, -- Male / Female
|
||||
email TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS email_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
recipient_name TEXT NOT NULL,
|
||||
recipient_email TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
sent_at TEXT NOT NULL, -- YYYY-MM-DD HH:MM:SS
|
||||
status TEXT NOT NULL, -- Sent / Failed
|
||||
detail TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS templates (
|
||||
gender TEXT PRIMARY KEY, -- Male / Female
|
||||
subject TEXT NOT NULL,
|
||||
body TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
# Seed default admin user
|
||||
if conn.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] == 0:
|
||||
conn.execute(
|
||||
"INSERT INTO users (username, password_hash, created_at) VALUES (?,?,?)",
|
||||
("admin", generate_password_hash("admin"),
|
||||
datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
|
||||
)
|
||||
print(" * Created default user 'admin' with password 'admin' - change it!")
|
||||
# Seed default templates
|
||||
for gender, body in (("Male", DEFAULT_TEMPLATE_MALE),
|
||||
("Female", DEFAULT_TEMPLATE_FEMALE)):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO templates (gender, subject, body) VALUES (?,?,?)",
|
||||
(gender, "Happy Birthday, {first_name}!", body),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_setting(conn, key, default=None):
|
||||
row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
||||
return row["value"] if row else default
|
||||
|
||||
|
||||
def set_setting(conn, key, value):
|
||||
conn.execute(
|
||||
"INSERT INTO settings (key, value) VALUES (?,?)"
|
||||
" ON CONFLICT(key) DO UPDATE SET value = excluded.value", (key, value))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_send_time(conn) -> tuple[int, int]:
|
||||
default = f'{int(os.environ.get("SEND_HOUR", "8")):02d}:00'
|
||||
raw = get_setting(conn, "send_time", default)
|
||||
try:
|
||||
h, m = raw.split(":")
|
||||
return max(0, min(23, int(h))), max(0, min(59, int(m)))
|
||||
except ValueError:
|
||||
return 8, 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- auth
|
||||
|
||||
def login_required(view):
|
||||
@wraps(view)
|
||||
def wrapped(*args, **kwargs):
|
||||
if not session.get("user_id"):
|
||||
return redirect(url_for("login", next=request.path))
|
||||
return view(*args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
user = get_db().execute(
|
||||
"SELECT * FROM users WHERE username = ?", (username,)
|
||||
).fetchone()
|
||||
if user and check_password_hash(user["password_hash"], password):
|
||||
if user["mfa_enabled"]:
|
||||
session.clear()
|
||||
session["mfa_pending_user"] = user["id"]
|
||||
return redirect(url_for("mfa_verify"))
|
||||
session.clear()
|
||||
session["user_id"] = user["id"]
|
||||
session["username"] = user["username"]
|
||||
return redirect(url_for("dashboard"))
|
||||
flash("Invalid username or password.", "error")
|
||||
return render_template("login.html", footer=FOOTER_TEXT)
|
||||
|
||||
|
||||
@app.route("/mfa-verify", methods=["GET", "POST"])
|
||||
def mfa_verify():
|
||||
pending = session.get("mfa_pending_user")
|
||||
if not pending:
|
||||
return redirect(url_for("login"))
|
||||
if request.method == "POST":
|
||||
code = request.form.get("code", "").strip().replace(" ", "")
|
||||
user = get_db().execute("SELECT * FROM users WHERE id = ?", (pending,)).fetchone()
|
||||
if user and user["mfa_secret"] and pyotp.TOTP(user["mfa_secret"]).verify(code, valid_window=1):
|
||||
session.clear()
|
||||
session["user_id"] = user["id"]
|
||||
session["username"] = user["username"]
|
||||
return redirect(url_for("dashboard"))
|
||||
flash("Invalid authentication code.", "error")
|
||||
return render_template("mfa_verify.html", footer=FOOTER_TEXT)
|
||||
|
||||
|
||||
@app.route("/logout")
|
||||
def logout():
|
||||
session.clear()
|
||||
return redirect(url_for("login"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- helpers
|
||||
|
||||
def calc_age(dob: date, on: date) -> int:
|
||||
years = on.year - dob.year
|
||||
if (on.month, on.day) < (dob.month, dob.day):
|
||||
years -= 1
|
||||
return years
|
||||
|
||||
|
||||
def birthday_matches_day(dob: date, day: date) -> bool:
|
||||
"""Feb 29 birthdays are celebrated on Feb 28 in non-leap years."""
|
||||
if dob.month == 2 and dob.day == 29 and not cal.isleap(day.year):
|
||||
return day.month == 2 and day.day == 28
|
||||
return dob.month == day.month and dob.day == day.day
|
||||
|
||||
|
||||
def birthdays_on(conn, day: date):
|
||||
rows = conn.execute("SELECT * FROM birthdays").fetchall()
|
||||
result = []
|
||||
for r in rows:
|
||||
dob = datetime.strptime(r["dob"], "%Y-%m-%d").date()
|
||||
if birthday_matches_day(dob, day):
|
||||
result.append((r, calc_age(dob, day)))
|
||||
return result
|
||||
|
||||
|
||||
def todays_birthdays(conn):
|
||||
return birthdays_on(conn, date.today())
|
||||
|
||||
|
||||
def render_placeholders(text: str, row, age: int) -> str:
|
||||
return (text.replace("{first_name}", row["first_name"])
|
||||
.replace("{last_name}", row["last_name"])
|
||||
.replace("{age}", str(age)))
|
||||
|
||||
|
||||
def smtp_settings():
|
||||
# Defaults are pre-configured for Purelymail (smtp.purelymail.com:465 SSL/TLS).
|
||||
# If your client only supports STARTTLS, set SMTP_PORT=587 and SMTP_SECURITY=starttls.
|
||||
return {
|
||||
"host": os.environ.get("SMTP_HOST", "smtp.purelymail.com"),
|
||||
"port": int(os.environ.get("SMTP_PORT", "465")),
|
||||
"user": os.environ.get("SMTP_USER", ""),
|
||||
"password": os.environ.get("SMTP_PASS", ""),
|
||||
"sender": os.environ.get("SMTP_FROM", os.environ.get("SMTP_USER", "birthday@localhost")),
|
||||
"security": os.environ.get("SMTP_SECURITY", "ssl").lower(), # ssl / starttls / none
|
||||
}
|
||||
|
||||
|
||||
def imap_settings():
|
||||
# Defaults are pre-configured for Purelymail (imap.purelymail.com:993 SSL/TLS).
|
||||
# IMAP credentials fall back to the SMTP ones if not set separately.
|
||||
return {
|
||||
"host": os.environ.get("IMAP_HOST", "imap.purelymail.com"),
|
||||
"port": int(os.environ.get("IMAP_PORT", "993")),
|
||||
"user": os.environ.get("IMAP_USER", os.environ.get("SMTP_USER", "")),
|
||||
"password": os.environ.get("IMAP_PASS", os.environ.get("SMTP_PASS", "")),
|
||||
"folder": os.environ.get("IMAP_SENT_FOLDER", "Sent"),
|
||||
}
|
||||
|
||||
|
||||
def save_to_sent_folder(msg) -> str:
|
||||
"""Append the sent message to the mailbox's Sent folder via IMAP.
|
||||
Returns '' on success or an error description on failure."""
|
||||
cfg = imap_settings()
|
||||
if not cfg["user"]:
|
||||
return "IMAP not configured - message not copied to Sent folder"
|
||||
try:
|
||||
imap = imaplib.IMAP4_SSL(cfg["host"], cfg["port"], timeout=20)
|
||||
imap.login(cfg["user"], cfg["password"])
|
||||
folder = cfg["folder"]
|
||||
# Create the folder if the server doesn't have it yet
|
||||
status, _ = imap.select(folder)
|
||||
if status != "OK":
|
||||
imap.create(folder)
|
||||
imap.append(folder, r"(\Seen)", imaplib.Time2Internaldate(datetime.now().timestamp()),
|
||||
msg.as_bytes())
|
||||
imap.logout()
|
||||
return ""
|
||||
except Exception as exc:
|
||||
return f"Delivered, but copy to Sent folder failed: {exc}"
|
||||
|
||||
|
||||
IMG_SRC_RE = re.compile(r'src="/uploads/([^"]+)"')
|
||||
|
||||
|
||||
def build_email(row, age: int, conn) -> MIMEMultipart:
|
||||
tpl = conn.execute(
|
||||
"SELECT * FROM templates WHERE gender = ?", (row["gender"],)
|
||||
).fetchone()
|
||||
subject = render_placeholders(tpl["subject"], row, age)
|
||||
body_html = render_placeholders(tpl["body"], row, age)
|
||||
|
||||
msg = MIMEMultipart("related")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = smtp_settings()["sender"]
|
||||
msg["To"] = row["email"]
|
||||
|
||||
# Embed uploaded images referenced in the template as inline attachments
|
||||
inline = []
|
||||
def to_cid(match):
|
||||
fname = match.group(1)
|
||||
path = os.path.join(UPLOAD_DIR, secure_filename(fname))
|
||||
if os.path.isfile(path):
|
||||
cid = uuid.uuid4().hex
|
||||
inline.append((path, cid))
|
||||
return f'src="cid:{cid}"'
|
||||
return match.group(0)
|
||||
|
||||
body_html = IMG_SRC_RE.sub(to_cid, body_html)
|
||||
|
||||
alt = MIMEMultipart("alternative")
|
||||
alt.attach(MIMEText(re.sub(r"<[^>]+>", " ", body_html), "plain", "utf-8"))
|
||||
alt.attach(MIMEText(body_html, "html", "utf-8"))
|
||||
msg.attach(alt)
|
||||
|
||||
for path, cid in inline:
|
||||
with open(path, "rb") as f:
|
||||
img = MIMEImage(f.read())
|
||||
img.add_header("Content-ID", f"<{cid}>")
|
||||
img.add_header("Content-Disposition", "inline", filename=os.path.basename(path))
|
||||
msg.attach(img)
|
||||
return msg
|
||||
|
||||
|
||||
def send_birthday_email(row, age: int, conn) -> None:
|
||||
cfg = smtp_settings()
|
||||
msg = build_email(row, age, conn)
|
||||
status, detail = "Sent", ""
|
||||
try:
|
||||
if not cfg["host"]:
|
||||
raise RuntimeError("SMTP is not configured (set SMTP_HOST etc.)")
|
||||
if cfg["security"] == "ssl":
|
||||
server = smtplib.SMTP_SSL(cfg["host"], cfg["port"], timeout=20)
|
||||
else:
|
||||
server = smtplib.SMTP(cfg["host"], cfg["port"], timeout=20)
|
||||
if cfg["security"] == "starttls":
|
||||
server.starttls()
|
||||
if cfg["user"]:
|
||||
server.login(cfg["user"], cfg["password"])
|
||||
server.send_message(msg)
|
||||
server.quit()
|
||||
detail = save_to_sent_folder(msg) # keep a copy in the mailbox Sent folder
|
||||
except Exception as exc: # log failures too
|
||||
status, detail = "Failed", str(exc)
|
||||
conn.execute(
|
||||
"INSERT INTO email_log (recipient_name, recipient_email, subject, sent_at, status, detail)"
|
||||
" VALUES (?,?,?,?,?,?)",
|
||||
(f'{row["first_name"]} {row["last_name"]}', row["email"], msg["Subject"],
|
||||
datetime.now().strftime("%Y-%m-%d %H:%M:%S"), status, detail),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def already_sent_today(conn, row) -> bool:
|
||||
today = date.today().strftime("%Y-%m-%d")
|
||||
hit = conn.execute(
|
||||
"SELECT 1 FROM email_log WHERE recipient_email = ? AND status = 'Sent'"
|
||||
" AND sent_at LIKE ?", (row["email"], today + "%"),
|
||||
).fetchone()
|
||||
return hit is not None
|
||||
|
||||
|
||||
def daily_birthday_job():
|
||||
conn = db_connect()
|
||||
try:
|
||||
for row, age in todays_birthdays(conn):
|
||||
if not already_sent_today(conn, row):
|
||||
send_birthday_email(row, age, conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- page 1
|
||||
|
||||
MONTH_NAMES = ["January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December"]
|
||||
|
||||
|
||||
def build_year_calendar(conn, year: int):
|
||||
"""Return list of month dicts with week grids; birthday days carry names."""
|
||||
rows = conn.execute("SELECT * FROM birthdays").fetchall()
|
||||
marks = {}
|
||||
for r in rows:
|
||||
dob = datetime.strptime(r["dob"], "%Y-%m-%d").date()
|
||||
m, d = dob.month, dob.day
|
||||
if m == 2 and d == 29 and not cal.isleap(year):
|
||||
d = 28
|
||||
marks.setdefault((m, d), []).append(f'{r["first_name"]} {r["last_name"]}')
|
||||
months = []
|
||||
c = cal.Calendar(firstweekday=0) # Monday
|
||||
today = date.today()
|
||||
for m in range(1, 13):
|
||||
weeks = []
|
||||
for week in c.monthdayscalendar(year, m):
|
||||
weeks.append([
|
||||
{
|
||||
"day": d or "",
|
||||
"names": marks.get((m, d), []) if d else [],
|
||||
"today": bool(d) and today.year == year and today.month == m and today.day == d,
|
||||
} for d in week
|
||||
])
|
||||
months.append({"name": MONTH_NAMES[m - 1], "weeks": weeks})
|
||||
return months
|
||||
|
||||
|
||||
@app.route("/", methods=["GET"])
|
||||
@login_required
|
||||
def dashboard():
|
||||
conn = get_db()
|
||||
year = request.args.get("year", type=int) or date.today().year
|
||||
entries = conn.execute(
|
||||
"SELECT * FROM birthdays ORDER BY substr(dob,6) ASC"
|
||||
).fetchall()
|
||||
from datetime import timedelta
|
||||
today = date.today()
|
||||
today_list = [
|
||||
{"row": r, "age": a, "sent": already_sent_today(conn, r)}
|
||||
for r, a in todays_birthdays(conn)
|
||||
]
|
||||
yesterday_list = [{"row": r, "age": a} for r, a in birthdays_on(conn, today - timedelta(days=1))]
|
||||
tomorrow_list = [{"row": r, "age": a} for r, a in birthdays_on(conn, today + timedelta(days=1))]
|
||||
return render_template(
|
||||
"dashboard.html",
|
||||
footer=FOOTER_TEXT,
|
||||
username=session.get("username"),
|
||||
entries=entries,
|
||||
today_list=today_list,
|
||||
yesterday_list=yesterday_list,
|
||||
tomorrow_list=tomorrow_list,
|
||||
today=today,
|
||||
months=build_year_calendar(conn, year),
|
||||
year=year,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/birthdays/add", methods=["POST"])
|
||||
@login_required
|
||||
def add_birthday():
|
||||
f = request.form
|
||||
first = f.get("first_name", "").strip()
|
||||
last = f.get("last_name", "").strip()
|
||||
dob = f.get("dob", "").strip()
|
||||
gender = f.get("gender", "").strip()
|
||||
email = f.get("email", "").strip()
|
||||
try:
|
||||
datetime.strptime(dob, "%Y-%m-%d")
|
||||
assert first and last and email and gender in ("Male", "Female")
|
||||
except (ValueError, AssertionError):
|
||||
flash("Please fill in all fields with valid values.", "error")
|
||||
return redirect(url_for("dashboard"))
|
||||
get_db().execute(
|
||||
"INSERT INTO birthdays (first_name, last_name, dob, gender, email) VALUES (?,?,?,?,?)",
|
||||
(first, last, dob, gender, email),
|
||||
)
|
||||
get_db().commit()
|
||||
flash(f"Added {first} {last}.", "ok")
|
||||
return redirect(url_for("dashboard"))
|
||||
|
||||
|
||||
@app.route("/birthdays/<int:bid>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_birthday(bid):
|
||||
get_db().execute("DELETE FROM birthdays WHERE id = ?", (bid,))
|
||||
get_db().commit()
|
||||
flash("Entry removed.", "ok")
|
||||
return redirect(url_for("dashboard"))
|
||||
|
||||
|
||||
@app.route("/force-send", methods=["POST"])
|
||||
@login_required
|
||||
def force_send():
|
||||
conn = get_db()
|
||||
todays = todays_birthdays(conn)
|
||||
if not todays:
|
||||
flash("Nobody has a birthday today - nothing to send.", "error")
|
||||
else:
|
||||
for row, age in todays:
|
||||
send_birthday_email(row, age, conn)
|
||||
flash(f"Birthday e-mail resent to {len(todays)} recipient(s). Check the log on the Administration page.", "ok")
|
||||
return redirect(url_for("dashboard"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- page 2
|
||||
|
||||
@app.route("/admin")
|
||||
@login_required
|
||||
def admin():
|
||||
conn = get_db()
|
||||
users = conn.execute("SELECT id, username, mfa_enabled, created_at FROM users ORDER BY username").fetchall()
|
||||
log = conn.execute("SELECT * FROM email_log ORDER BY sent_at DESC, id DESC LIMIT 500").fetchall()
|
||||
tpl_male = conn.execute("SELECT * FROM templates WHERE gender='Male'").fetchone()
|
||||
tpl_female = conn.execute("SELECT * FROM templates WHERE gender='Female'").fetchone()
|
||||
images = sorted(os.listdir(UPLOAD_DIR))
|
||||
h, m = get_send_time(conn)
|
||||
return render_template(
|
||||
"admin.html",
|
||||
send_time=f"{h:02d}:{m:02d}",
|
||||
footer=FOOTER_TEXT,
|
||||
username=session.get("username"),
|
||||
user_id=session.get("user_id"),
|
||||
users=users,
|
||||
log=log,
|
||||
tpl_male=tpl_male,
|
||||
tpl_female=tpl_female,
|
||||
images=images,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/users/add", methods=["POST"])
|
||||
@login_required
|
||||
def add_user():
|
||||
username = request.form.get("username", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
if not username or len(password) < 4:
|
||||
flash("Username and a password of at least 4 characters are required.", "error")
|
||||
return redirect(url_for("admin"))
|
||||
try:
|
||||
get_db().execute(
|
||||
"INSERT INTO users (username, password_hash, created_at) VALUES (?,?,?)",
|
||||
(username, generate_password_hash(password),
|
||||
datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
|
||||
)
|
||||
get_db().commit()
|
||||
flash(f"User '{username}' created.", "ok")
|
||||
except sqlite3.IntegrityError:
|
||||
flash("That username already exists.", "error")
|
||||
return redirect(url_for("admin"))
|
||||
|
||||
|
||||
@app.route("/users/<int:uid>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_user(uid):
|
||||
conn = get_db()
|
||||
if uid == session.get("user_id"):
|
||||
flash("You cannot delete the account you are logged in with.", "error")
|
||||
elif conn.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] <= 1:
|
||||
flash("At least one user must remain.", "error")
|
||||
else:
|
||||
conn.execute("DELETE FROM users WHERE id = ?", (uid,))
|
||||
conn.commit()
|
||||
flash("User removed.", "ok")
|
||||
return redirect(url_for("admin"))
|
||||
|
||||
|
||||
@app.route("/account/password", methods=["POST"])
|
||||
@login_required
|
||||
def change_password():
|
||||
current = request.form.get("current", "")
|
||||
new = request.form.get("new", "")
|
||||
confirm = request.form.get("confirm", "")
|
||||
conn = get_db()
|
||||
user = conn.execute("SELECT * FROM users WHERE id = ?", (session["user_id"],)).fetchone()
|
||||
if not check_password_hash(user["password_hash"], current):
|
||||
flash("Current password is incorrect.", "error")
|
||||
elif len(new) < 4:
|
||||
flash("New password must be at least 4 characters.", "error")
|
||||
elif new != confirm:
|
||||
flash("New passwords do not match.", "error")
|
||||
else:
|
||||
conn.execute("UPDATE users SET password_hash = ? WHERE id = ?",
|
||||
(generate_password_hash(new), user["id"]))
|
||||
conn.commit()
|
||||
flash("Password changed.", "ok")
|
||||
return redirect(url_for("admin"))
|
||||
|
||||
|
||||
@app.route("/account/mfa/setup", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def mfa_setup():
|
||||
conn = get_db()
|
||||
user = conn.execute("SELECT * FROM users WHERE id = ?", (session["user_id"],)).fetchone()
|
||||
if request.method == "POST":
|
||||
code = request.form.get("code", "").strip().replace(" ", "")
|
||||
secret = session.get("mfa_setup_secret")
|
||||
if secret and pyotp.TOTP(secret).verify(code, valid_window=1):
|
||||
conn.execute("UPDATE users SET mfa_secret = ?, mfa_enabled = 1 WHERE id = ?",
|
||||
(secret, user["id"]))
|
||||
conn.commit()
|
||||
session.pop("mfa_setup_secret", None)
|
||||
flash("Two-factor authentication is now enabled.", "ok")
|
||||
return redirect(url_for("admin"))
|
||||
flash("That code did not match. Scan the QR code and try again.", "error")
|
||||
secret = session.get("mfa_setup_secret") or pyotp.random_base32()
|
||||
session["mfa_setup_secret"] = secret
|
||||
uri = pyotp.TOTP(secret).provisioning_uri(
|
||||
name=user["username"], issuer_name="Birthday Manager")
|
||||
buf = io.BytesIO()
|
||||
qrcode.make(uri).save(buf, format="PNG")
|
||||
qr_b64 = base64.b64encode(buf.getvalue()).decode()
|
||||
return render_template("mfa_setup.html", footer=FOOTER_TEXT,
|
||||
username=session.get("username"),
|
||||
qr=qr_b64, secret=secret)
|
||||
|
||||
|
||||
@app.route("/account/mfa/disable", methods=["POST"])
|
||||
@login_required
|
||||
def mfa_disable():
|
||||
conn = get_db()
|
||||
conn.execute("UPDATE users SET mfa_enabled = 0, mfa_secret = NULL WHERE id = ?",
|
||||
(session["user_id"],))
|
||||
conn.commit()
|
||||
flash("Two-factor authentication disabled for your account.", "ok")
|
||||
return redirect(url_for("admin"))
|
||||
|
||||
|
||||
@app.route("/settings/send-time", methods=["POST"])
|
||||
@login_required
|
||||
def save_send_time():
|
||||
raw = request.form.get("send_time", "").strip()
|
||||
try:
|
||||
h, m = raw.split(":")
|
||||
h, m = int(h), int(m)
|
||||
assert 0 <= h <= 23 and 0 <= m <= 59
|
||||
except (ValueError, AssertionError):
|
||||
flash("Please pick a valid time.", "error")
|
||||
return redirect(url_for("admin"))
|
||||
set_setting(get_db(), "send_time", f"{h:02d}:{m:02d}")
|
||||
scheduler.reschedule_job("daily-birthday-mail", trigger="cron", hour=h, minute=m)
|
||||
flash(f"Automatic birthday e-mails will now be sent daily at {h:02d}:{m:02d}.", "ok")
|
||||
return redirect(url_for("admin"))
|
||||
|
||||
|
||||
@app.route("/templates/save", methods=["POST"])
|
||||
@login_required
|
||||
def save_template():
|
||||
gender = request.form.get("gender")
|
||||
if gender not in ("Male", "Female"):
|
||||
flash("Unknown template.", "error")
|
||||
return redirect(url_for("admin"))
|
||||
subject = request.form.get("subject", "").strip() or "Happy Birthday, {first_name}!"
|
||||
body = request.form.get("body", "").strip()
|
||||
get_db().execute("UPDATE templates SET subject = ?, body = ? WHERE gender = ?",
|
||||
(subject, body, gender))
|
||||
get_db().commit()
|
||||
flash(f"E-mail template ({gender}) saved.", "ok")
|
||||
return redirect(url_for("admin"))
|
||||
|
||||
|
||||
@app.route("/images/upload", methods=["POST"])
|
||||
@login_required
|
||||
def upload_image():
|
||||
files = [f for f in request.files.getlist("images") if f and f.filename]
|
||||
if not files:
|
||||
flash("Choose one or more image files to upload.", "error")
|
||||
return redirect(url_for("admin"))
|
||||
# Enforce the 15 MB total limit across all files in this upload
|
||||
total = 0
|
||||
for f in files:
|
||||
f.stream.seek(0, os.SEEK_END)
|
||||
total += f.stream.tell()
|
||||
f.stream.seek(0)
|
||||
if total > 15 * 1024 * 1024:
|
||||
flash(f"Upload rejected: total size {total / (1024*1024):.1f} MB exceeds the 15 MB limit.", "error")
|
||||
return redirect(url_for("admin"))
|
||||
saved, skipped = [], []
|
||||
for f in files:
|
||||
name = secure_filename(f.filename)
|
||||
ext = os.path.splitext(name)[1].lower()
|
||||
if ext not in ALLOWED_IMAGE_EXT:
|
||||
skipped.append(f.filename)
|
||||
continue
|
||||
unique = f"{os.path.splitext(name)[0]}-{uuid.uuid4().hex[:6]}{ext}"
|
||||
f.save(os.path.join(UPLOAD_DIR, unique))
|
||||
saved.append(unique)
|
||||
if saved:
|
||||
flash(f"{len(saved)} image(s) uploaded. Copy the <img> command shown under each image to use it in a template.", "ok")
|
||||
if skipped:
|
||||
flash(f"Skipped (not PNG/JPG/GIF/WEBP): {', '.join(skipped)}", "error")
|
||||
return redirect(url_for("admin"))
|
||||
|
||||
|
||||
@app.errorhandler(413)
|
||||
def too_large(_e):
|
||||
flash("Upload rejected: the total upload size exceeds the 15 MB limit.", "error")
|
||||
return redirect(url_for("admin"))
|
||||
|
||||
|
||||
@app.route("/images/<path:fname>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_image(fname):
|
||||
path = os.path.join(UPLOAD_DIR, secure_filename(fname))
|
||||
if os.path.isfile(path):
|
||||
os.remove(path)
|
||||
flash("Image deleted.", "ok")
|
||||
return redirect(url_for("admin"))
|
||||
|
||||
|
||||
@app.route("/uploads/<path:fname>")
|
||||
@login_required
|
||||
def serve_upload(fname):
|
||||
return send_from_directory(UPLOAD_DIR, secure_filename(fname))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- startup
|
||||
|
||||
init_db()
|
||||
scheduler = BackgroundScheduler(daemon=True)
|
||||
_conn = db_connect()
|
||||
_h, _m = get_send_time(_conn)
|
||||
_conn.close()
|
||||
scheduler.add_job(daily_birthday_job, "cron", hour=_h, minute=_m,
|
||||
id="daily-birthday-mail")
|
||||
scheduler.start()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "8080")), debug=False)
|
||||
Reference in New Issue
Block a user