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:
1
routes/__init__.py
Normal file
1
routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Route registration modules."""
|
||||
304
routes/admin.py
Normal file
304
routes/admin.py
Normal 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
41
routes/assets.py
Normal 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
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"))
|
||||
96
routes/pages.py
Normal file
96
routes/pages.py
Normal 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
310
routes/settings.py
Normal 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)
|
||||
Reference in New Issue
Block a user