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:
2026-07-03 02:29:46 -05:00
parent cdba720a1c
commit 6cf35ca034
64 changed files with 3902 additions and 3776 deletions

290
routes/auth.py Normal file
View 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"))