Files
docker-ep-web-server/app.py
T

466 lines
18 KiB
Python

import os
import re
import sqlite3
import smtplib
from base64 import b64encode
from io import BytesIO
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
from flask_wtf.csrf import CSRFProtect
import pyotp
import qrcode
from werkzeug.security import check_password_hash, generate_password_hash
BASE_DIR = Path(__file__).resolve().parent
DATABASE = Path(os.environ.get("DATABASE_PATH", BASE_DIR / "data" / "eternity.db"))
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("/"),
)
csrf = CSRFProtect(app)
def get_db():
if "db" not in g:
app.config["DATABASE"].parent.mkdir(parents=True, exist_ok=True)
g.db = sqlite3.connect(app.config["DATABASE"])
g.db.row_factory = sqlite3.Row
return g.db
@app.teardown_appcontext
def close_db(_error=None):
db = g.pop("db", None)
if db is not None:
db.close()
def init_db():
db = get_db()
db.executescript(
"""
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,
role TEXT NOT NULL DEFAULT 'member',
mfa_secret TEXT,
mfa_enabled INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
excerpt TEXT NOT NULL,
body TEXT NOT NULL,
category TEXT NOT NULL,
author_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
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)")}
migrations = {
"is_approved": "ALTER TABLE users ADD COLUMN is_approved INTEGER NOT NULL DEFAULT 0",
"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")
admin = db.execute("SELECT id FROM users WHERE username = 'admin'").fetchone()
if admin is None:
db.execute(
"""INSERT INTO users (username, password_hash, created_at, is_approved, role)
VALUES (?, ?, ?, 1, 'admin')""",
("admin", generate_password_hash("admin"), datetime.now(timezone.utc).isoformat()),
)
db.commit()
def login_required(view):
@wraps(view)
def wrapped_view(*args, **kwargs):
if "user_id" not in session or not session.get("mfa_verified"):
flash("Sign in to continue.", "notice")
return redirect(url_for("login", next=request.path))
return view(*args, **kwargs)
return wrapped_view
def admin_required(view):
@wraps(view)
@login_required
def wrapped_view(*args, **kwargs):
user = get_db().execute("SELECT role FROM users WHERE id = ?", (session["user_id"],)).fetchone()
if user is None or user["role"] != "admin":
abort(403)
return view(*args, **kwargs)
return wrapped_view
def unique_slug(title, post_id=None):
base = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "untitled"
slug = base
counter = 2
db = get_db()
while True:
row = db.execute("SELECT id FROM posts WHERE slug = ?", (slug,)).fetchone()
if row is None or row["id"] == post_id:
return slug
slug = f"{base}-{counter}"
counter += 1
def post_from_request(post_id=None):
title = request.form.get("title", "").strip()
excerpt = request.form.get("excerpt", "").strip()
body = request.form.get("body", "").strip()
category = request.form.get("category", "Engineering").strip() or "Engineering"
if not title or not excerpt or not body:
raise ValueError("Title, summary, and article body are required.")
now = datetime.now(timezone.utc).isoformat()
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
if "user_id" in session:
user = get_db().execute("SELECT id, username, role FROM users WHERE id = ?", (session["user_id"],)).fetchone()
return {"current_user": user}
@app.route("/")
def index():
posts = get_db().execute(
"""SELECT posts.*, users.username FROM posts JOIN users ON users.id = posts.author_id
ORDER BY posts.created_at DESC"""
).fetchall()
return render_template("index.html", posts=posts)
@app.route("/post/<slug>")
def post(slug):
article = get_db().execute(
"""SELECT posts.*, users.username FROM posts JOIN users ON users.id = posts.author_id
WHERE posts.slug = ?""",
(slug,),
).fetchone()
if article is None:
abort(404)
return render_template("post.html", post=article)
@app.route("/register", methods=("GET", "POST"))
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, 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 or email address is already in use.", "error")
return render_template("auth.html", mode="register")
@app.route("/login", methods=("GET", "POST"))
def login():
if request.method == "POST":
username = request.form.get("username", "").strip().lower()
user = get_db().execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
if user is None or not check_password_hash(user["password_hash"], request.form.get("password", "")):
flash("Invalid handle or password.", "error")
elif not user["is_approved"]:
flash("Your account is awaiting administrator approval.", "notice")
else:
session.clear()
session["mfa_pending_user_id"] = user["id"]
session["login_next"] = request.args.get("next") or url_for("index")
if user["mfa_enabled"]:
return redirect(url_for("mfa_verify"))
return redirect(url_for("mfa_setup"))
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()
return redirect(url_for("index"))
def pending_mfa_user():
user_id = session.get("mfa_pending_user_id")
if user_id is None:
return None
return get_db().execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
def mfa_qr_code(secret, username):
uri = pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name="Eternity Project")
image = qrcode.make(uri)
buffer = BytesIO()
image.save(buffer, format="PNG")
return f"data:image/png;base64,{b64encode(buffer.getvalue()).decode('ascii')}"
@app.route("/mfa/setup", methods=("GET", "POST"))
def mfa_setup():
user = pending_mfa_user()
if user is None or user["mfa_enabled"]:
return redirect(url_for("login"))
secret = session.setdefault("mfa_setup_secret", pyotp.random_base32())
if request.method == "POST":
if pyotp.TOTP(secret).verify(request.form.get("code", ""), valid_window=1):
get_db().execute("UPDATE users SET mfa_secret = ?, mfa_enabled = 1 WHERE id = ?", (secret, user["id"]))
get_db().commit()
next_url = session.get("login_next", url_for("index"))
session.clear()
session["user_id"] = user["id"]
session["mfa_verified"] = True
return redirect(next_url)
flash("That verification code was not accepted. Try the current code.", "error")
return render_template(
"mfa.html", mode="setup", secret=secret, username=user["username"], qr_code=mfa_qr_code(secret, user["username"])
)
@app.route("/mfa/verify", methods=("GET", "POST"))
def mfa_verify():
user = pending_mfa_user()
if user is None or not user["mfa_enabled"] or not user["mfa_secret"]:
return redirect(url_for("login"))
if request.method == "POST":
if pyotp.TOTP(user["mfa_secret"]).verify(request.form.get("code", ""), valid_window=1):
next_url = session.get("login_next", url_for("index"))
session.clear()
session["user_id"] = user["id"]
session["mfa_verified"] = True
return redirect(next_url)
flash("That verification code was not accepted. Try the current code.", "error")
return render_template("mfa.html", mode="verify", username=user["username"])
@app.get("/admin/users")
@admin_required
def admin_users():
users = get_db().execute(
"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)
@app.post("/admin/users/<int:user_id>/approve")
@admin_required
def approve_user(user_id):
cursor = get_db().execute("UPDATE users SET is_approved = 1 WHERE id = ? AND role = 'member'", (user_id,))
get_db().commit()
flash("Account approved." if cursor.rowcount else "No pending member account was changed.", "success")
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():
if request.method == "POST":
try:
title, slug, excerpt, body, category, now = post_from_request()
get_db().execute(
"""INSERT INTO posts (title, slug, excerpt, body, category, author_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(title, slug, excerpt, body, category, session["user_id"], now, now),
)
get_db().commit()
return redirect(url_for("post", slug=slug))
except ValueError as error:
flash(str(error), "error")
return render_template("editor.html", post=None)
@app.route("/post/<slug>/edit", methods=("GET", "POST"))
@login_required
def edit_post(slug):
article = get_db().execute("SELECT * FROM posts WHERE slug = ?", (slug,)).fetchone()
if article is None:
abort(404)
if article["author_id"] != session["user_id"]:
abort(403)
if request.method == "POST":
try:
title, new_slug, excerpt, body, category, now = post_from_request(article["id"])
get_db().execute(
"""UPDATE posts SET title=?, slug=?, excerpt=?, body=?, category=?, updated_at=? WHERE id=?""",
(title, new_slug, excerpt, body, category, now, article["id"]),
)
get_db().commit()
return redirect(url_for("post", slug=new_slug))
except ValueError as error:
flash(str(error), "error")
return render_template("editor.html", post=article)
with app.app_context():
init_db()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000)