import os import re import sqlite3 from datetime import datetime, timezone from functools import wraps from pathlib import Path from flask import Flask, abort, flash, g, redirect, render_template, request, session, url_for import pyotp 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, ) 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, 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) ); """ ) 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", } for column, statement in migrations.items(): if column not in user_columns: db.execute(statement) # 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 @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/") 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() 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") 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()), ) 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") 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.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() @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"]) @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, 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//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.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//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)