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

1
routes/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Route registration modules."""

304
routes/admin.py Normal file
View File

@@ -0,0 +1,304 @@
"""Admin route registration."""
import logging
import os
import shutil
from datetime import datetime
from flask import flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required
from database import db
from extensions import get_polling_service
from models import PollLog, PollSource
from services import load_platform_config, post_service
logger = logging.getLogger(__name__)
def _require_admin(redirect_endpoint="index", message="Access denied"):
if current_user.is_admin:
return None
flash(message, "error")
return redirect(url_for(redirect_endpoint))
def register_admin_routes(app, user_service):
"""Register admin routes while preserving endpoint names."""
@app.route("/admin")
@login_required
def admin_panel():
"""Admin panel - user management."""
denied = _require_admin("index", "Access denied. Admin privileges required.")
if denied:
return denied
if not user_service:
flash("User service not available", "error")
return redirect(url_for("index"))
users = user_service.get_all_users()
return render_template("admin.html", users=users)
@app.route("/admin/user/<user_id>/delete", methods=["POST"])
@login_required
def admin_delete_user(user_id):
"""Delete user (admin only)."""
denied = _require_admin("index")
if denied:
return denied
if current_user.id == user_id:
flash("You cannot delete your own account!", "error")
return redirect(url_for("admin_panel"))
user = user_service.get_user_by_id(user_id)
if user:
username = user.username
if user_service.delete_user(user_id):
flash(f"User {username} has been deleted.", "success")
logger.info(f"Admin {current_user.id} deleted user {username} ({user_id})")
else:
flash("Error deleting user", "error")
logger.error(f"Failed to delete user {user_id}")
else:
flash("User not found", "error")
return redirect(url_for("admin_panel"))
@app.route("/admin/user/<user_id>/toggle-admin", methods=["POST"])
@login_required
def admin_toggle_admin(user_id):
"""Toggle user admin status."""
denied = _require_admin("index")
if denied:
return denied
target_user = user_service.get_user_by_id(user_id)
if target_user:
user_service.update_user_admin_status(user_id, not target_user.is_admin)
flash("Admin status updated", "success")
else:
flash("User not found", "error")
return redirect(url_for("admin_panel"))
@app.route("/admin/clear_cache", methods=["POST"])
@login_required
def admin_clear_cache():
"""Clear application cache."""
denied = _require_admin("admin_panel")
if denied:
return denied
try:
for cache_dir in ["cache", "temp"]:
if os.path.exists(cache_dir):
shutil.rmtree(cache_dir)
post_service.invalidate()
flash("Cache cleared successfully", "success")
logger.info(f"Cache cleared by admin user {current_user.id}")
except Exception as e:
flash(f"Error clearing cache: {str(e)}", "error")
logger.error(f"Cache clearing error: {e}")
return redirect(url_for("admin_panel"))
@app.route("/admin/backup_data", methods=["POST"])
@login_required
def admin_backup_data():
"""Create backup of application data."""
denied = _require_admin("admin_panel")
if denied:
return denied
try:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_name = f"balanceboard_backup_{timestamp}"
backup_dir = f"backups/{backup_name}"
os.makedirs(backup_dir, exist_ok=True)
for dir_name in ["data", "templates", "themes", "static"]:
if os.path.exists(dir_name):
shutil.copytree(dir_name, f"{backup_dir}/{dir_name}")
for file_name in ["app.py", "models.py", "database.py", "filtersets.json"]:
if os.path.exists(file_name):
shutil.copy2(file_name, backup_dir)
flash(f"Backup created: {backup_name}", "success")
except Exception as e:
flash(f"Error creating backup: {str(e)}", "error")
return redirect(url_for("admin_panel"))
@app.route("/admin/polling")
@login_required
def admin_polling():
"""Admin polling management page."""
denied = _require_admin("index", "Access denied. Admin privileges required.")
if denied:
return denied
sources = PollSource.query.order_by(PollSource.platform, PollSource.display_name).all()
scheduler_status = get_polling_service().get_status()
platform_config = load_platform_config()
return render_template(
"admin_polling.html",
sources=sources,
scheduler_status=scheduler_status,
platform_config=platform_config,
)
@app.route("/admin/polling/add", methods=["POST"])
@login_required
def admin_polling_add():
"""Add a new poll source."""
denied = _require_admin("index")
if denied:
return denied
platform = request.form.get("platform")
source_id = request.form.get("source_id")
custom_source_id = request.form.get("custom_source_id")
display_name = request.form.get("display_name")
poll_interval = int(request.form.get("poll_interval", 60))
max_posts = int(request.form.get("max_posts", 100))
fetch_comments = request.form.get("fetch_comments", "true") == "true"
priority = request.form.get("priority", "medium")
if custom_source_id and custom_source_id.strip():
source_id = custom_source_id.strip()
if not platform or not source_id or not display_name:
flash("Missing required fields", "error")
return redirect(url_for("admin_polling"))
existing = PollSource.query.filter_by(platform=platform, source_id=source_id).first()
if existing:
flash(f"Source {platform}:{source_id} already exists", "warning")
return redirect(url_for("admin_polling"))
source = PollSource(
platform=platform,
source_id=source_id,
display_name=display_name,
poll_interval_minutes=poll_interval,
max_posts=max_posts,
fetch_comments=fetch_comments,
priority=priority,
enabled=False,
created_by=current_user.id,
)
db.session.add(source)
db.session.commit()
flash(f"Added polling source: {display_name}", "success")
logger.info(f"Admin {current_user.id} added poll source {platform}:{source_id}")
return redirect(url_for("admin_polling"))
@app.route("/admin/polling/<source_id>/toggle", methods=["POST"])
@login_required
def admin_polling_toggle(source_id):
"""Toggle a poll source on/off."""
denied = _require_admin("index")
if denied:
return denied
source = PollSource.query.get(source_id)
if not source:
flash("Source not found", "error")
return redirect(url_for("admin_polling"))
source.enabled = not source.enabled
db.session.commit()
status = "enabled" if source.enabled else "disabled"
flash(f"Polling {status} for {source.display_name}", "success")
return redirect(url_for("admin_polling"))
@app.route("/admin/polling/<source_id>/update", methods=["POST"])
@login_required
def admin_polling_update(source_id):
"""Update poll source configuration."""
denied = _require_admin("index")
if denied:
return denied
source = PollSource.query.get(source_id)
if not source:
flash("Source not found", "error")
return redirect(url_for("admin_polling"))
if request.form.get("poll_interval"):
source.poll_interval_minutes = int(request.form.get("poll_interval"))
if request.form.get("max_posts"):
source.max_posts = int(request.form.get("max_posts"))
if request.form.get("fetch_comments") is not None:
source.fetch_comments = request.form.get("fetch_comments") == "true"
if request.form.get("priority"):
source.priority = request.form.get("priority")
if request.form.get("display_name"):
source.display_name = request.form.get("display_name")
db.session.commit()
flash(f"Updated settings for {source.display_name}", "success")
return redirect(url_for("admin_polling"))
@app.route("/admin/polling/<source_id>/poll-now", methods=["POST"])
@login_required
def admin_polling_poll_now(source_id):
"""Manually trigger polling for a source."""
denied = _require_admin("index")
if denied:
return denied
source = PollSource.query.get(source_id)
if not source:
flash("Source not found", "error")
return redirect(url_for("admin_polling"))
try:
get_polling_service().poll_now(source_id)
flash(f"Polling started for {source.display_name}", "success")
except Exception as e:
flash(f"Error starting poll: {str(e)}", "error")
logger.error(f"Error triggering poll for {source_id}: {e}")
return redirect(url_for("admin_polling"))
@app.route("/admin/polling/<source_id>/delete", methods=["POST"])
@login_required
def admin_polling_delete(source_id):
"""Delete a poll source."""
denied = _require_admin("index")
if denied:
return denied
source = PollSource.query.get(source_id)
if not source:
flash("Source not found", "error")
return redirect(url_for("admin_polling"))
display_name = source.display_name
db.session.delete(source)
db.session.commit()
flash(f"Deleted polling source: {display_name}", "success")
logger.info(f"Admin {current_user.id} deleted poll source {source_id}")
return redirect(url_for("admin_polling"))
@app.route("/admin/polling/<source_id>/logs")
@login_required
def admin_polling_logs(source_id):
"""View logs for a specific poll source."""
denied = _require_admin("index")
if denied:
return denied
source = PollSource.query.get(source_id)
if not source:
flash("Source not found", "error")
return redirect(url_for("admin_polling"))
logs = source.logs.limit(50).all()
return render_template("admin_polling_logs.html", source=source, logs=logs)

41
routes/assets.py Normal file
View File

@@ -0,0 +1,41 @@
"""Asset and static-file route registration."""
import logging
import os
from flask import abort, current_app, send_from_directory
from security import is_safe_path
logger = logging.getLogger(__name__)
def register_asset_routes(app):
"""Register asset routes while preserving legacy endpoint names."""
@app.route("/themes/<path:filename>")
def serve_theme(filename):
"""Serve theme files (CSS, JS)."""
if not is_safe_path(filename) or ".." in filename:
logger.warning(f"Unsafe theme file requested: {filename}")
abort(404)
return send_from_directory("themes", filename)
@app.route("/logo.png")
def serve_logo():
"""Serve configurable logo."""
logo_path = current_app.config["LOGO_PATH"]
if "/" not in logo_path:
return send_from_directory(".", logo_path)
directory = os.path.dirname(logo_path)
filename = os.path.basename(logo_path)
return send_from_directory(directory, filename)
@app.route("/static/<path:filename>")
def serve_static(filename):
"""Serve static files (avatars, etc.)."""
if not is_safe_path(filename) or ".." in filename:
logger.warning(f"Unsafe static file requested: {filename}")
abort(404)
return send_from_directory("static", filename)

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"))

96
routes/pages.py Normal file
View File

@@ -0,0 +1,96 @@
"""Page route registration for the legacy Jinja UI."""
import json
import logging
from flask import current_app, redirect, render_template, url_for
from flask_login import current_user, login_required
from services import get_display_name_for_source, load_platform_config, post_service
logger = logging.getLogger(__name__)
def _load_user_settings():
if not current_user.is_authenticated:
return {}
try:
return json.loads(current_user.settings) if current_user.settings else {}
except (json.JSONDecodeError, TypeError):
return {}
def register_page_routes(app):
"""Register legacy page routes while preserving endpoint names."""
@app.route("/")
def index():
"""Serve the main feed page."""
quick_stats = post_service.quick_stats()
if current_user.is_authenticated:
return render_template(
"dashboard.html",
user_settings=_load_user_settings(),
quick_stats=quick_stats,
)
if current_app.config.get("ALLOW_ANONYMOUS_ACCESS", False):
user_settings = {
"filter_set": "no_filter",
"communities": [],
"experience": {
"infinite_scroll": False,
"auto_refresh": False,
"push_notifications": False,
"dark_patterns_opt_in": False,
"time_filter_enabled": False,
"time_filter_days": 7,
},
}
return render_template(
"dashboard.html",
user_settings=user_settings,
anonymous=True,
quick_stats=quick_stats,
)
return redirect(url_for("login"))
@app.route("/bookmarks")
@login_required
def bookmarks():
"""Bookmarks page."""
return render_template("bookmarks.html", user=current_user)
@app.route("/post/<post_id>")
def post_detail(post_id):
"""Serve individual post detail page with modern theme."""
try:
platform_config = load_platform_config()
cached_posts, cached_comments = post_service.load()
post_data = cached_posts.get(post_id)
if not post_data:
return render_template("404.html"), 404
post = dict(post_data)
post["source_display"] = get_display_name_for_source(
post.get("platform", ""),
post.get("source", ""),
platform_config,
)
comments_flat = cached_comments.get(post_id, [])
logger.info(f"Loading post {post_id}: found {len(comments_flat)} comments")
comments = post_service.build_comment_tree(comments_flat)
return render_template(
"post_detail.html",
post=post,
comments=comments,
user_settings=_load_user_settings(),
)
except Exception as e:
logger.error(f"Error loading post {post_id}: {e}")
return render_template("404.html"), 404

310
routes/settings.py Normal file
View File

@@ -0,0 +1,310 @@
"""Settings and profile route registration."""
import json
import logging
import os
from flask import current_app, flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required
from werkzeug.utils import secure_filename
from config import MAX_FILENAME_LENGTH, UPLOAD_FOLDER
from database import db
from extensions import get_filter_engine
from security import is_allowed_file, is_safe_filterset
from services import SettingsService, load_platform_config
logger = logging.getLogger(__name__)
DEFAULT_AVATARS = [
{"id": "default_1", "name": "Gradient Blue", "bg": "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"},
{"id": "default_2", "name": "Gradient Green", "bg": "linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)"},
{"id": "default_3", "name": "Gradient Orange", "bg": "linear-gradient(135deg, #fa709a 0%, #fee140 100%)"},
{"id": "default_4", "name": "Gradient Purple", "bg": "linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)"},
{"id": "default_5", "name": "Brand Colors", "bg": "linear-gradient(135deg, #4db6ac 0%, #26a69a 100%)"},
{"id": "default_6", "name": "Sunset", "bg": "linear-gradient(135deg, #ff7e5f 0%, #feb47b 100%)"},
]
def register_settings_routes(app, user_service):
"""Register settings/profile routes while preserving endpoint names."""
@app.route("/settings")
@login_required
def settings():
"""Main settings page."""
user_settings = SettingsService.parse(current_user.settings)
try:
with open("filtersets.json", "r", encoding="utf-8") as f:
filter_sets = json.load(f)
except (FileNotFoundError, json.JSONDecodeError, IOError):
filter_sets = {}
return render_template(
"settings.html",
user=current_user,
user_settings=user_settings,
filter_sets=filter_sets,
)
@app.route("/settings/profile", methods=["GET", "POST"])
@login_required
def settings_profile():
"""Profile settings page."""
if request.method == "POST":
username = request.form.get("username")
email = request.form.get("email")
default_avatar = request.form.get("default_avatar")
if not username or not email:
flash("Username and email are required", "error")
return render_template("settings_profile.html", user=current_user)
if username != current_user.username and user_service.username_exists(username):
flash("Username already taken", "error")
return render_template("settings_profile.html", user=current_user)
if email != current_user.email and user_service.email_exists(email):
flash("Email already registered", "error")
return render_template("settings_profile.html", user=current_user)
current_user.username = username
current_user.email = email
if default_avatar and default_avatar.startswith("default_"):
current_user.profile_picture_url = f"/static/default-avatars/{default_avatar}.png"
db.session.commit()
flash("Profile updated successfully", "success")
return redirect(url_for("settings"))
return render_template(
"settings_profile.html",
user=current_user,
default_avatars=DEFAULT_AVATARS,
)
@app.route("/settings/communities", methods=["GET", "POST"])
@login_required
def settings_communities():
"""Community/source selection settings."""
if request.method == "POST":
user_settings = SettingsService.parse(current_user.settings)
user_settings["communities"] = request.form.getlist("communities")
current_user.settings = json.dumps(user_settings)
db.session.commit()
flash("Community preferences updated", "success")
return redirect(url_for("settings"))
user_settings = SettingsService.parse(current_user.settings)
selected_communities = user_settings.get("communities", [])
available_communities = []
try:
platform_config = load_platform_config() or {"platforms": {}, "collection_targets": []}
except Exception as e:
logger.error(f"Error loading platform config: {e}")
platform_config = {"platforms": {}, "collection_targets": []}
enabled_communities = set()
try:
for target in platform_config.get("collection_targets", []):
if "platform" in target and "community" in target:
enabled_communities.add((target["platform"], target["community"]))
except Exception as e:
logger.error(f"Error processing collection_targets: {e}")
try:
for platform_name, platform_info in platform_config.get("platforms", {}).items():
if not isinstance(platform_info, dict):
continue
communities = platform_info.get("communities", [])
if not isinstance(communities, list):
continue
for community_info in communities:
try:
if not isinstance(community_info, dict):
continue
if (platform_name, community_info["id"]) in enabled_communities:
available_communities.append(
{
"id": community_info["id"],
"name": community_info["name"],
"display_name": community_info.get("display_name", community_info["name"]),
"platform": platform_name,
"icon": community_info.get("icon", platform_info.get("icon", "\U0001f4c4")),
"description": community_info.get("description", ""),
}
)
except Exception as e:
logger.error(f"Error processing community {community_info}: {e}")
continue
except Exception as e:
logger.error(f"Error building community list: {e}")
logger.info(f"Found {len(available_communities)} available communities")
return render_template(
"settings_communities.html",
user=current_user,
available_communities=available_communities,
selected_communities=selected_communities,
)
@app.route("/settings/filters", methods=["GET", "POST"])
@login_required
def settings_filters():
"""Filter settings page."""
if request.method == "POST":
selected_filter = request.form.get("filter_set", "no_filter")
user_settings = SettingsService.validate(current_user.settings)
if is_safe_filterset(selected_filter):
user_settings["filter_set"] = selected_filter
else:
flash("Invalid filter selection", "error")
return redirect(url_for("settings"))
try:
current_user.settings = json.dumps(user_settings)
db.session.commit()
flash("Filter settings updated successfully", "success")
except Exception as e:
db.session.rollback()
logger.error(f"Error saving filter settings for user {current_user.id}: {e}")
flash("Error saving settings", "error")
return redirect(url_for("settings"))
user_settings = SettingsService.parse(current_user.settings)
current_filter = user_settings.get("filter_set", "no_filter")
filter_engine = get_filter_engine()
filter_sets = {
filterset_name: filter_engine.config.get_filterset(filterset_name)
for filterset_name in filter_engine.get_available_filtersets()
}
return render_template(
"settings_filters.html",
user=current_user,
filter_sets=filter_sets,
current_filter=current_filter,
)
@app.route("/settings/experience", methods=["GET", "POST"])
@login_required
def settings_experience():
"""Experience and behavioral settings page."""
if request.method == "POST":
user_settings = SettingsService.parse(current_user.settings)
user_settings["experience"] = {
"infinite_scroll": request.form.get("infinite_scroll") == "on",
"auto_refresh": request.form.get("auto_refresh") == "on",
"push_notifications": request.form.get("push_notifications") == "on",
"dark_patterns_opt_in": request.form.get("dark_patterns_opt_in") == "on",
"time_filter_enabled": request.form.get("time_filter_enabled") == "on",
"time_filter_days": int(request.form.get("time_filter_days", 7)),
}
current_user.settings = json.dumps(user_settings)
db.session.commit()
flash("Experience settings updated successfully", "success")
return redirect(url_for("settings"))
return render_template(
"settings_experience.html",
user=current_user,
experience_settings=SettingsService.experience_settings(current_user.settings),
)
@app.route("/upload-avatar", methods=["POST"])
@login_required
def upload_avatar():
"""Upload profile picture."""
try:
logger.info(f"Avatar upload attempt by user {current_user.id} ({current_user.username})")
logger.debug(f"Request files: {list(request.files.keys())}")
logger.debug(f"Request form: {dict(request.form)}")
if not hasattr(current_user, "id") or not current_user.id:
logger.error("User missing ID attribute")
flash("Authentication error. Please log in again.", "error")
return redirect(url_for("login"))
if not hasattr(current_user, "username") or not current_user.username:
logger.error("User missing username attribute")
flash("User profile incomplete. Please update your profile.", "error")
return redirect(url_for("settings_profile"))
if "avatar" not in request.files:
logger.warning("No avatar file in request")
flash("No file selected", "error")
return redirect(url_for("settings_profile"))
file = request.files["avatar"]
if file.filename == "":
logger.warning("Empty filename provided")
flash("No file selected", "error")
return redirect(url_for("settings_profile"))
logger.info(f"Processing file: {file.filename}")
if not is_allowed_file(file.filename):
logger.warning(f"Invalid file type: {file.filename}")
flash("Invalid file type. Please upload PNG, JPG, or GIF", "error")
return redirect(url_for("settings_profile"))
max_content_length = current_app.config.get("MAX_CONTENT_LENGTH", 16 * 1024 * 1024)
if hasattr(file, "content_length") and file.content_length > max_content_length:
logger.warning(f"File too large: {file.content_length}")
flash("File too large. Maximum size is 16MB", "error")
return redirect(url_for("settings_profile"))
filename = secure_filename(file.filename)
if not filename or len(filename) > MAX_FILENAME_LENGTH:
logger.warning(f"Invalid filename after sanitization: {filename}")
flash("Invalid filename", "error")
return redirect(url_for("settings_profile"))
unique_filename = f"{current_user.id}_{filename}"
logger.info(f"Generated unique filename: {unique_filename}")
upload_dir = os.path.abspath(UPLOAD_FOLDER)
os.makedirs(upload_dir, exist_ok=True)
upload_path = os.path.join(upload_dir, unique_filename)
if not os.path.abspath(upload_path).startswith(upload_dir):
logger.warning(f"Path traversal attempt in file upload: {upload_path}")
flash("Invalid file path", "error")
return redirect(url_for("settings_profile"))
file.save(upload_path)
logger.info(f"File saved successfully: {upload_path}")
old_avatar_url = current_user.profile_picture_url
current_user.profile_picture_url = f"/static/avatars/{unique_filename}"
db.session.commit()
logger.info(f"User profile updated successfully for {current_user.username}")
if old_avatar_url and old_avatar_url.startswith("/static/avatars/") and current_user.id in old_avatar_url:
try:
old_file_path = os.path.join(upload_dir, os.path.basename(old_avatar_url))
if os.path.exists(old_file_path):
os.remove(old_file_path)
logger.info(f"Cleaned up old avatar: {old_file_path}")
except Exception as e:
logger.warning(f"Could not clean up old avatar: {e}")
flash("Profile picture updated successfully", "success")
return redirect(url_for("settings_profile"))
except Exception as e:
logger.error(f"Unexpected error in avatar upload: {e}")
db.session.rollback()
flash("An unexpected error occurred. Please try again.", "error")
return redirect(url_for("settings_profile"))
@app.route("/profile")
@login_required
def profile():
"""User profile page."""
return render_template("profile.html", user=current_user)