Add partial email support and todo list
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import smtplib
|
||||
from base64 import b64encode
|
||||
from io import BytesIO
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email.message import EmailMessage
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from secrets import token_urlsafe
|
||||
from hashlib import sha256
|
||||
|
||||
from flask import Flask, abort, flash, g, redirect, render_template, request, session, url_for
|
||||
import pyotp
|
||||
@@ -19,6 +23,12 @@ app = Flask(__name__)
|
||||
app.config.update(
|
||||
SECRET_KEY=os.environ.get("SECRET_KEY", "change-this-secret-before-production"),
|
||||
DATABASE=DATABASE,
|
||||
MAIL_HOST=os.environ.get("MAIL_HOST"),
|
||||
MAIL_PORT=int(os.environ.get("MAIL_PORT", "587")),
|
||||
MAIL_USERNAME=os.environ.get("MAIL_USERNAME"),
|
||||
MAIL_PASSWORD=os.environ.get("MAIL_PASSWORD"),
|
||||
MAIL_FROM=os.environ.get("MAIL_FROM", "noreply@eternityproject.fi"),
|
||||
PUBLIC_URL=os.environ.get("PUBLIC_URL", "http://localhost:8000").rstrip("/"),
|
||||
)
|
||||
|
||||
|
||||
@@ -44,6 +54,7 @@ def init_db():
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
is_approved INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -63,6 +74,15 @@ def init_db():
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (author_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
token_hash TEXT UNIQUE NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
user_columns = {row["name"] for row in db.execute("PRAGMA table_info(users)")}
|
||||
@@ -71,10 +91,12 @@ def init_db():
|
||||
"role": "ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'member'",
|
||||
"mfa_secret": "ALTER TABLE users ADD COLUMN mfa_secret TEXT",
|
||||
"mfa_enabled": "ALTER TABLE users ADD COLUMN mfa_enabled INTEGER NOT NULL DEFAULT 0",
|
||||
"email": "ALTER TABLE users ADD COLUMN email TEXT",
|
||||
}
|
||||
for column, statement in migrations.items():
|
||||
if column not in user_columns:
|
||||
db.execute(statement)
|
||||
db.execute("CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique ON users(email)")
|
||||
|
||||
# Preserve access for accounts created before approvals were introduced.
|
||||
db.execute("UPDATE users SET is_approved = 1 WHERE role = 'member' AND is_approved = 0")
|
||||
@@ -135,6 +157,40 @@ def post_from_request(post_id=None):
|
||||
return title, unique_slug(title, post_id), excerpt, body, category, now
|
||||
|
||||
|
||||
def valid_email(email):
|
||||
return re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", email) is not None
|
||||
|
||||
|
||||
def create_reset_token(user_id):
|
||||
token = token_urlsafe(32)
|
||||
now = datetime.now(timezone.utc)
|
||||
get_db().execute("UPDATE password_reset_tokens SET used_at = ? WHERE user_id = ? AND used_at IS NULL", (now.isoformat(), user_id))
|
||||
get_db().execute(
|
||||
"""INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(user_id, sha256(token.encode("utf-8")).hexdigest(), (now + timedelta(hours=1)).isoformat(), now.isoformat()),
|
||||
)
|
||||
get_db().commit()
|
||||
return token
|
||||
|
||||
|
||||
def send_reset_email(user):
|
||||
if not app.config["MAIL_HOST"]:
|
||||
raise RuntimeError("Email is not configured. Set MAIL_HOST and related SMTP settings.")
|
||||
token = create_reset_token(user["id"])
|
||||
reset_url = f"{app.config['PUBLIC_URL']}{url_for('reset_password', token=token)}"
|
||||
message = EmailMessage()
|
||||
message["Subject"] = "Reset your Eternity Project password"
|
||||
message["From"] = app.config["MAIL_FROM"]
|
||||
message["To"] = user["email"]
|
||||
message.set_content(f"Hello {user['username']},\n\nUse this link within one hour to choose a new password:\n{reset_url}\n\nIf you did not request this, you can ignore this email.")
|
||||
with smtplib.SMTP(app.config["MAIL_HOST"], app.config["MAIL_PORT"]) as client:
|
||||
client.starttls()
|
||||
if app.config["MAIL_USERNAME"]:
|
||||
client.login(app.config["MAIL_USERNAME"], app.config["MAIL_PASSWORD"])
|
||||
client.send_message(message)
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_current_user():
|
||||
user = None
|
||||
@@ -168,22 +224,25 @@ def post(slug):
|
||||
def register():
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip().lower()
|
||||
email = request.form.get("email", "").strip().lower()
|
||||
password = request.form.get("password", "")
|
||||
if not re.fullmatch(r"[a-z0-9_-]{3,32}", username):
|
||||
flash("Use 3-32 lowercase letters, numbers, hyphens, or underscores.", "error")
|
||||
elif len(password) < 10:
|
||||
flash("Choose a password with at least 10 characters.", "error")
|
||||
elif not valid_email(email):
|
||||
flash("Enter a valid email address.", "error")
|
||||
else:
|
||||
try:
|
||||
get_db().execute(
|
||||
"INSERT INTO users (username, password_hash, created_at, is_approved) VALUES (?, ?, ?, 0)",
|
||||
(username, generate_password_hash(password), datetime.now(timezone.utc).isoformat()),
|
||||
"INSERT INTO users (username, email, password_hash, created_at, is_approved) VALUES (?, ?, ?, ?, 0)",
|
||||
(username, email, generate_password_hash(password), datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
get_db().commit()
|
||||
flash("Registration received. An administrator must approve it before you can sign in.", "success")
|
||||
return redirect(url_for("login"))
|
||||
except sqlite3.IntegrityError:
|
||||
flash("That handle is already in use.", "error")
|
||||
flash("That handle or email address is already in use.", "error")
|
||||
return render_template("auth.html", mode="register")
|
||||
|
||||
|
||||
@@ -206,6 +265,64 @@ def login():
|
||||
return render_template("auth.html", mode="login")
|
||||
|
||||
|
||||
@app.route("/password/forgot", methods=("GET", "POST"))
|
||||
def forgot_password():
|
||||
if request.method == "POST":
|
||||
email = request.form.get("email", "").strip().lower()
|
||||
user = get_db().execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone()
|
||||
if user is not None:
|
||||
try:
|
||||
send_reset_email(user)
|
||||
except (RuntimeError, OSError, smtplib.SMTPException):
|
||||
app.logger.exception("Unable to send password reset email")
|
||||
flash("If that address belongs to an account, a reset link has been sent.", "success")
|
||||
return redirect(url_for("login"))
|
||||
return render_template("password_form.html", mode="forgot")
|
||||
|
||||
|
||||
@app.route("/password/reset/<token>", methods=("GET", "POST"))
|
||||
def reset_password(token):
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
reset = get_db().execute(
|
||||
"""SELECT * FROM password_reset_tokens WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?""",
|
||||
(sha256(token.encode("utf-8")).hexdigest(), now),
|
||||
).fetchone()
|
||||
if reset is None:
|
||||
flash("That password reset link is invalid or has expired.", "error")
|
||||
return redirect(url_for("forgot_password"))
|
||||
if request.method == "POST":
|
||||
password = request.form.get("password", "")
|
||||
if len(password) < 10:
|
||||
flash("Choose a password with at least 10 characters.", "error")
|
||||
else:
|
||||
db = get_db()
|
||||
db.execute("UPDATE users SET password_hash = ? WHERE id = ?", (generate_password_hash(password), reset["user_id"]))
|
||||
db.execute("UPDATE password_reset_tokens SET used_at = ? WHERE id = ?", (now, reset["id"]))
|
||||
db.commit()
|
||||
flash("Password reset. Sign in with your new password.", "success")
|
||||
return redirect(url_for("login"))
|
||||
return render_template("password_form.html", mode="reset")
|
||||
|
||||
|
||||
@app.route("/account/password", methods=("GET", "POST"))
|
||||
@login_required
|
||||
def change_password():
|
||||
if request.method == "POST":
|
||||
user = get_db().execute("SELECT password_hash FROM users WHERE id = ?", (session["user_id"],)).fetchone()
|
||||
current_password = request.form.get("current_password", "")
|
||||
new_password = request.form.get("password", "")
|
||||
if not check_password_hash(user["password_hash"], current_password):
|
||||
flash("Your current password is incorrect.", "error")
|
||||
elif len(new_password) < 10:
|
||||
flash("Choose a new password with at least 10 characters.", "error")
|
||||
else:
|
||||
get_db().execute("UPDATE users SET password_hash = ? WHERE id = ?", (generate_password_hash(new_password), session["user_id"]))
|
||||
get_db().commit()
|
||||
flash("Password updated.", "success")
|
||||
return redirect(url_for("index"))
|
||||
return render_template("password_form.html", mode="change")
|
||||
|
||||
|
||||
@app.post("/logout")
|
||||
def logout():
|
||||
session.clear()
|
||||
@@ -268,7 +385,7 @@ def mfa_verify():
|
||||
@admin_required
|
||||
def admin_users():
|
||||
users = get_db().execute(
|
||||
"SELECT id, username, created_at, is_approved, mfa_enabled, role FROM users ORDER BY is_approved, created_at DESC"
|
||||
"SELECT id, username, email, created_at, is_approved, mfa_enabled, role FROM users ORDER BY is_approved, created_at DESC"
|
||||
).fetchall()
|
||||
return render_template("admin_users.html", users=users)
|
||||
|
||||
@@ -282,6 +399,22 @@ def approve_user(user_id):
|
||||
return redirect(url_for("admin_users"))
|
||||
|
||||
|
||||
@app.post("/admin/users/<int:user_id>/password-reset")
|
||||
@admin_required
|
||||
def admin_password_reset(user_id):
|
||||
user = get_db().execute("SELECT * FROM users WHERE id = ? AND role = 'member'", (user_id,)).fetchone()
|
||||
if user is None or not user["email"]:
|
||||
flash("That member does not have an email address for password recovery.", "error")
|
||||
else:
|
||||
try:
|
||||
send_reset_email(user)
|
||||
flash(f"Password reset email sent to {user['email']}.", "success")
|
||||
except (RuntimeError, OSError, smtplib.SMTPException):
|
||||
app.logger.exception("Unable to send administrator password reset email")
|
||||
flash("Password reset email could not be sent. Check SMTP settings.", "error")
|
||||
return redirect(url_for("admin_users"))
|
||||
|
||||
|
||||
@app.route("/write", methods=("GET", "POST"))
|
||||
@login_required
|
||||
def write():
|
||||
|
||||
Reference in New Issue
Block a user