Phase 1-3 + 6: pluggable filter system, app factory, test harness
Implements the parallel.md workstreams (Agents A-E) toward REFACTOR_GOAL.md. Phase 1 — app factory + blueprints + /api/v1: - app.py -> create_app() factory (no module-level app); entrypoints updated - routes/ (auth, pages, settings, admin, assets) + blueprints/api.py at /api/v1 - config.py / extensions.py / security.py extracted; services/ layer added - endpoint names preserved so template url_for() calls keep resolving (static check: all 27 template url_for endpoints are defined routes) Phase 2 — one pluggable filter system: - filter_pipeline/registry.py: @register_stage / @register_plugin + discover_modules - engine._init_stages() instantiates registered stages (no hardcoded dict); process_batch is AI-aware: only short-circuits to the AI-disabled path when a filterset's stages declare requires_ai, so offline filtersets run with AI off - BaseFilterPlugin gets a consumer (stages/plugins.py); Keyword/Quality re-enabled via filter_config.json plugins config - comment tree modes ported to stages/comment_filter.py + shared rules.py; wired into /api/v1/posts/<uuid> and /api/v1/comments/<uuid> via FilterEngine.filter_comments() (fails open) - offline quality_filter filterset exercises plugins+ranker without AI - legacy filter_lib / comment_lib / html_generation_lib / generate_html / active_html path deleted Phase 3 prep — pluggable fetchers + Postgres models: - Post / Comment SQLAlchemy models added to models.py - migrate_content_to_db.py backfill (idempotent by uuid, batched, --dry-run) - platforms/ fetcher registry (extension point) - live reads/writes still go through PostService (disk JSON); cutover deferred Phase 6 — test harness: - pytest.ini + tests/ (conftest with in-memory SQLite fixture, no Postgres; stubbed polling/filter singletons) - test_app_factory.py (route registration, no module-level app), test_api_contracts.py (posts/post_detail/comments/filters shape with monkeypatched post_service + get_filter_engine), test_filter_pipeline.py + test_plugin_contract.py (Flask-free; validated locally 12/12 incl. drop-in stage/plugin discovered with zero core edits) Other: .gitignore added (__pycache__, data/, secrets); pytest in requirements. Verification: py_compile clean across the project; the Flask-free filter-pipeline and plugin-contract tests pass locally. App-factory / API-contract tests need deps+docker to run; runtime flask routes / Auth0-repeated-create_app also gated on docker. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
290
routes/auth.py
Normal file
290
routes/auth.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""Authentication route registration."""
|
||||
|
||||
import logging
|
||||
from urllib.parse import quote_plus, urlencode
|
||||
|
||||
from flask import current_app, flash, redirect, render_template, request, session, url_for
|
||||
from flask_login import current_user, login_required, login_user, logout_user
|
||||
|
||||
from config import MAX_USERNAME_LENGTH, MIN_PASSWORD_LENGTH
|
||||
from models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register_auth_routes(app, user_service, auth0_client):
|
||||
"""Register auth routes while preserving legacy endpoint names."""
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
"""Login page."""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("index"))
|
||||
|
||||
auth0_configured = bool(
|
||||
current_app.config.get("AUTH0_DOMAIN")
|
||||
and current_app.config.get("AUTH0_CLIENT_ID")
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username")
|
||||
password = request.form.get("password")
|
||||
remember = request.form.get("remember", False) == "on"
|
||||
|
||||
if not user_service:
|
||||
flash("User service not available", "error")
|
||||
return render_template("login.html", auth0_configured=auth0_configured)
|
||||
|
||||
user = user_service.authenticate(username, password)
|
||||
if user:
|
||||
login_user(user, remember=remember)
|
||||
flash(f"Welcome back, {user.username}!", "success")
|
||||
next_page = request.args.get("next")
|
||||
return redirect(next_page) if next_page else redirect(url_for("index"))
|
||||
|
||||
flash("Invalid username or password", "error")
|
||||
|
||||
return render_template("login.html", auth0_configured=auth0_configured)
|
||||
|
||||
@app.route("/password-reset-request", methods=["GET", "POST"])
|
||||
def password_reset_request():
|
||||
"""Request a password reset."""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("index"))
|
||||
|
||||
if request.method == "POST":
|
||||
email = request.form.get("email", "").strip().lower()
|
||||
if not email:
|
||||
flash("Please enter your email address", "error")
|
||||
return render_template("password_reset_request.html")
|
||||
|
||||
user = User.query.filter_by(email=email).first()
|
||||
flash(
|
||||
"If an account exists with that email, a password reset link has been sent.",
|
||||
"success",
|
||||
)
|
||||
|
||||
if user and user.password_hash:
|
||||
token = user.generate_reset_token()
|
||||
reset_url = url_for("password_reset", token=token, _external=True)
|
||||
logger.info(f"Password reset requested for {email}. Reset URL: {reset_url}")
|
||||
flash(f"Reset link (development only): {reset_url}", "info")
|
||||
|
||||
return redirect(url_for("login"))
|
||||
|
||||
return render_template("password_reset_request.html")
|
||||
|
||||
@app.route("/password-reset/<token>", methods=["GET", "POST"])
|
||||
def password_reset(token):
|
||||
"""Reset password with token."""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("index"))
|
||||
|
||||
user = User.query.filter_by(reset_token=token).first()
|
||||
if not user or not user.verify_reset_token(token):
|
||||
flash("Invalid or expired reset token", "error")
|
||||
return redirect(url_for("login"))
|
||||
|
||||
if request.method == "POST":
|
||||
password = request.form.get("password", "")
|
||||
confirm_password = request.form.get("confirm_password", "")
|
||||
|
||||
if not password or len(password) < MIN_PASSWORD_LENGTH:
|
||||
flash(f"Password must be at least {MIN_PASSWORD_LENGTH} characters", "error")
|
||||
return render_template("password_reset.html")
|
||||
|
||||
if password != confirm_password:
|
||||
flash("Passwords do not match", "error")
|
||||
return render_template("password_reset.html")
|
||||
|
||||
user.set_password(password)
|
||||
user.clear_reset_token()
|
||||
flash("Your password has been reset successfully. You can now log in.", "success")
|
||||
return redirect(url_for("login"))
|
||||
|
||||
return render_template("password_reset.html")
|
||||
|
||||
@app.route("/auth0/login")
|
||||
def auth0_login():
|
||||
"""Redirect to Auth0 for authentication."""
|
||||
if not current_app.config.get("AUTH0_DOMAIN") or not current_app.config.get("AUTH0_CLIENT_ID"):
|
||||
flash(
|
||||
"Auth0 authentication is not configured. Please use email/password login or contact the administrator.",
|
||||
"error",
|
||||
)
|
||||
return redirect(url_for("login"))
|
||||
|
||||
try:
|
||||
redirect_uri = url_for("auth0_callback", _external=True)
|
||||
return auth0_client.authorize_redirect(redirect_uri)
|
||||
except Exception as e:
|
||||
logger.error(f"Auth0 login error: {e}")
|
||||
flash("Auth0 authentication failed. Please use email/password login.", "error")
|
||||
return redirect(url_for("login"))
|
||||
|
||||
@app.route("/auth0/callback")
|
||||
def auth0_callback():
|
||||
"""Handle Auth0 callback and create/login user."""
|
||||
try:
|
||||
token = auth0_client.authorize_access_token()
|
||||
user_info = token.get("userinfo")
|
||||
if not user_info:
|
||||
user_info = auth0_client.parse_id_token(token)
|
||||
|
||||
auth0_id = user_info.get("sub")
|
||||
email = user_info.get("email")
|
||||
username = (
|
||||
user_info.get("nickname")
|
||||
or user_info.get("preferred_username")
|
||||
or email.split("@")[0]
|
||||
)
|
||||
|
||||
if not auth0_id or not email:
|
||||
flash("Unable to get user information from Auth0", "error")
|
||||
return redirect(url_for("login"))
|
||||
|
||||
user = user_service.get_user_by_auth0_id(auth0_id)
|
||||
if not user:
|
||||
existing_user = user_service.get_user_by_email(email)
|
||||
if existing_user:
|
||||
user_service.link_auth0_account(existing_user.id, auth0_id)
|
||||
user = existing_user
|
||||
flash(f"Account linked successfully! Welcome back, {user.username}!", "success")
|
||||
else:
|
||||
base_username = username[:MAX_USERNAME_LENGTH - 3]
|
||||
unique_username = base_username
|
||||
counter = 1
|
||||
while user_service.username_exists(unique_username):
|
||||
unique_username = f"{base_username}_{counter}"
|
||||
counter += 1
|
||||
|
||||
user_id = user_service.create_user(
|
||||
username=unique_username,
|
||||
email=email,
|
||||
password=None,
|
||||
is_admin=False,
|
||||
auth0_id=auth0_id,
|
||||
)
|
||||
if user_id:
|
||||
user = user_service.get_user_by_id(user_id)
|
||||
flash(f"Account created successfully! Welcome, {user.username}!", "success")
|
||||
else:
|
||||
flash("Failed to create user account", "error")
|
||||
return redirect(url_for("login"))
|
||||
else:
|
||||
flash(f"Welcome back, {user.username}!", "success")
|
||||
|
||||
if user:
|
||||
login_user(user, remember=True)
|
||||
session["auth0_user_info"] = user_info
|
||||
next_page = request.args.get("next")
|
||||
return redirect(next_page) if next_page else redirect(url_for("index"))
|
||||
except Exception as e:
|
||||
logger.error(f"Auth0 callback error: {e}")
|
||||
flash("Authentication failed. Please try again.", "error")
|
||||
|
||||
return redirect(url_for("login"))
|
||||
|
||||
@app.route("/auth0/logout")
|
||||
@login_required
|
||||
def auth0_logout():
|
||||
"""Logout from Auth0 and local session."""
|
||||
session.clear()
|
||||
logout_user()
|
||||
|
||||
domain = current_app.config["AUTH0_DOMAIN"]
|
||||
client_id = current_app.config["AUTH0_CLIENT_ID"]
|
||||
return_to = url_for("index", _external=True)
|
||||
logout_url = f"https://{domain}/v2/logout?" + urlencode(
|
||||
{"returnTo": return_to, "client_id": client_id}, quote_via=quote_plus
|
||||
)
|
||||
return redirect(logout_url)
|
||||
|
||||
@app.route("/admin-setup", methods=["GET", "POST"])
|
||||
def admin_setup():
|
||||
"""Create first admin user."""
|
||||
try:
|
||||
user_count = User.query.count()
|
||||
if user_count > 0:
|
||||
flash("Admin user already exists.", "info")
|
||||
return redirect(url_for("login"))
|
||||
except Exception as e:
|
||||
logger.warning(f"Database error checking existing users: {e}")
|
||||
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username")
|
||||
email = request.form.get("email")
|
||||
password = request.form.get("password")
|
||||
password_confirm = request.form.get("password_confirm")
|
||||
|
||||
if not username or not email or not password:
|
||||
flash("All fields are required", "error")
|
||||
return render_template("admin_setup.html")
|
||||
|
||||
if password != password_confirm:
|
||||
flash("Passwords do not match", "error")
|
||||
return render_template("admin_setup.html")
|
||||
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
flash(f"Password must be at least {MIN_PASSWORD_LENGTH} characters", "error")
|
||||
return render_template("admin_setup.html")
|
||||
|
||||
user_id = user_service.create_user(username, email, password, is_admin=True)
|
||||
if user_id:
|
||||
flash("Admin account created successfully! Please log in.", "success")
|
||||
return redirect(url_for("login"))
|
||||
flash("Error creating admin account. Please try again.", "error")
|
||||
|
||||
return render_template("admin_setup.html")
|
||||
|
||||
@app.route("/signup", methods=["GET", "POST"])
|
||||
def signup():
|
||||
"""Signup page."""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("index"))
|
||||
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username")
|
||||
email = request.form.get("email")
|
||||
password = request.form.get("password")
|
||||
password_confirm = request.form.get("password_confirm")
|
||||
|
||||
if not user_service:
|
||||
flash("User service not available", "error")
|
||||
return render_template("signup.html")
|
||||
|
||||
if not username or not email or not password:
|
||||
flash("All fields are required", "error")
|
||||
return render_template("signup.html")
|
||||
|
||||
if password != password_confirm:
|
||||
flash("Passwords do not match", "error")
|
||||
return render_template("signup.html")
|
||||
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
flash(f"Password must be at least {MIN_PASSWORD_LENGTH} characters", "error")
|
||||
return render_template("signup.html")
|
||||
|
||||
if user_service.username_exists(username):
|
||||
flash("Username already taken", "error")
|
||||
return render_template("signup.html")
|
||||
|
||||
if user_service.email_exists(email):
|
||||
flash("Email already registered", "error")
|
||||
return render_template("signup.html")
|
||||
|
||||
user_id = user_service.create_user(username, email, password)
|
||||
if user_id:
|
||||
flash("Account created successfully! Please log in.", "success")
|
||||
return redirect(url_for("login"))
|
||||
flash("Error creating account. Please try again.", "error")
|
||||
|
||||
return render_template("signup.html")
|
||||
|
||||
@app.route("/logout")
|
||||
@login_required
|
||||
def logout():
|
||||
"""Logout current user."""
|
||||
logout_user()
|
||||
flash("You have been logged out.", "info")
|
||||
return redirect(url_for("index"))
|
||||
Reference in New Issue
Block a user