505 lines
20 KiB
Python
505 lines
20 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 uuid import uuid4
|
|
|
|
from flask import Flask, abort, flash, g, redirect, render_template, request, send_from_directory, session, url_for
|
|
from flask_wtf.csrf import CSRFProtect
|
|
from PIL import Image, ImageOps, UnidentifiedImageError
|
|
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"))
|
|
UPLOAD_FOLDER = Path(os.environ.get("UPLOAD_FOLDER", BASE_DIR / "data" / "uploads"))
|
|
|
|
app = Flask(__name__)
|
|
app.config.update(
|
|
SECRET_KEY=os.environ.get("SECRET_KEY", "change-this-secret-before-production"),
|
|
DATABASE=DATABASE,
|
|
UPLOAD_FOLDER=UPLOAD_FOLDER,
|
|
MAX_CONTENT_LENGTH=8 * 1024 * 1024,
|
|
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,
|
|
image_filename TEXT,
|
|
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",
|
|
}
|
|
post_columns = {row["name"] for row in db.execute("PRAGMA table_info(posts)")}
|
|
for column, statement in migrations.items():
|
|
if column not in user_columns:
|
|
db.execute(statement)
|
|
if "image_filename" not in post_columns:
|
|
db.execute("ALTER TABLE posts ADD COLUMN image_filename TEXT")
|
|
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 save_post_image():
|
|
image_file = request.files.get("image")
|
|
if image_file is None or not image_file.filename:
|
|
return None
|
|
try:
|
|
image = Image.open(image_file.stream)
|
|
image.verify()
|
|
source_format = image.format
|
|
image_file.stream.seek(0)
|
|
image = ImageOps.exif_transpose(Image.open(image_file.stream)).convert("RGB")
|
|
except (UnidentifiedImageError, OSError, SyntaxError) as error:
|
|
raise ValueError("Upload a valid JPEG, PNG, or WebP image.") from error
|
|
if source_format not in {"JPEG", "PNG", "WEBP"}:
|
|
raise ValueError("Upload a JPEG, PNG, or WebP image.")
|
|
image.thumbnail((2400, 2400))
|
|
filename = f"{uuid4().hex}.webp"
|
|
app.config["UPLOAD_FOLDER"].mkdir(parents=True, exist_ok=True)
|
|
image.save(app.config["UPLOAD_FOLDER"] / filename, "WEBP", quality=88, method=6)
|
|
return filename
|
|
|
|
|
|
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.get("/uploads/<path:filename>")
|
|
def uploaded_image(filename):
|
|
return send_from_directory(app.config["UPLOAD_FOLDER"], filename)
|
|
|
|
|
|
@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()
|
|
image_filename = save_post_image()
|
|
get_db().execute(
|
|
"""INSERT INTO posts (title, slug, excerpt, body, category, image_filename, author_id, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(title, slug, excerpt, body, category, image_filename, 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"])
|
|
image_filename = save_post_image() or article["image_filename"]
|
|
get_db().execute(
|
|
"""UPDATE posts SET title=?, slug=?, excerpt=?, body=?, category=?, image_filename=?, updated_at=? WHERE id=?""",
|
|
(title, new_slug, excerpt, body, category, image_filename, now, article["id"]),
|
|
)
|
|
get_db().commit()
|
|
if image_filename != article["image_filename"] and article["image_filename"]:
|
|
(app.config["UPLOAD_FOLDER"] / article["image_filename"]).unlink(missing_ok=True)
|
|
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)
|