Phase 0: stop-the-bleeding bugfixes

- Defer polling_service.start() and FilterEngine init to a one-shot
  before_request hook so importing app.py no longer spawns a scheduler
  thread (also fixes migrate_*.py import side effects).
- Fix migrate_bookmarks.py: init_db returns None, so use the shared
  `db` instead of assigning its None return.
- Enforce MIN_PASSWORD_LENGTH (8) in the password-reset route for
  consistency with signup (was hardcoded 6).
- post_detail.html: replace undefined moment(...).fromNow() (always
  "Recently") with a new timeago Jinja filter + data-timestamp attrs
  that also drive the existing JS updater; make nl2br escape-then-Markup
  and drop | safe from comment/post content to close the XSS hole.
- filter_pipeline: when AI is disabled but a filterset requires it,
  pass posts through with status=FAILED + explicit error instead of
  silently degrading to no_filter.

No source-file mojibake found; content-encoding ingest is a Phase 3 concern.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-03 01:30:13 -05:00
parent 718cc36973
commit cdba720a1c
4 changed files with 108 additions and 23 deletions

78
app.py
View File

@@ -10,6 +10,7 @@ import time
from pathlib import Path
from werkzeug.utils import secure_filename
from flask import Flask, render_template, request, redirect, url_for, flash, send_from_directory, abort, session, jsonify
from markupsafe import escape, Markup
from flask_login import LoginManager, login_user, logout_user, login_required, current_user
from dotenv import load_dotenv
from functools import lru_cache
@@ -81,15 +82,36 @@ login_manager.login_message = 'Please log in to access this page.'
# Initialize user service
user_service = UserService()
# Initialize polling service
# Background services (polling scheduler + filter engine) are initialized
# lazily on the first request, NOT at import time. This keeps importing this
# module side-effect-free (no scheduler threads, no filter-engine init), so
# `from app import app` is safe for migration scripts and tests. The full
# app-factory split is deferred to Phase 1.
from polling_service import polling_service
polling_service.init_app(app)
polling_service.start()
# Initialize filter engine
from filter_pipeline import FilterEngine
filter_engine = FilterEngine.get_instance()
logger.info(f"FilterEngine initialized with {len(filter_engine.get_available_filtersets())} filtersets")
filter_engine = None # set lazily in _ensure_services_started()
_services_started = False
@app.before_request
def _ensure_services_started():
"""Start the polling scheduler and initialize the filter engine on the
first request, once. Kept out of module import so importing this module
has no side effects.
"""
global filter_engine, _services_started
if _services_started:
return
if filter_engine is None:
filter_engine = FilterEngine.get_instance()
logger.info(
f"FilterEngine initialized with "
f"{len(filter_engine.get_available_filtersets())} filtersets"
)
polling_service.init_app(app)
polling_service.start()
_services_started = True
# Initialize OAuth for Auth0
oauth = OAuth(app)
@@ -114,6 +136,10 @@ def _is_safe_filterset(filterset):
"""Validate filterset name for security"""
if not filterset or not isinstance(filterset, str):
return False
# filter_engine is initialized lazily on the first request; if it has not
# been initialized yet, fail closed.
if filter_engine is None:
return False
# Check against available filtersets from filter_engine
allowed_filtersets = set(filter_engine.get_available_filtersets())
return filterset in allowed_filtersets and re.match(r'^[a-zA-Z0-9_-]+$', filterset)
@@ -239,10 +265,40 @@ def _validate_user_settings(settings_str):
# Add custom Jinja filters
@app.template_filter('nl2br')
def nl2br_filter(text):
"""Convert newlines to <br> tags"""
"""Convert newlines to <br> tags.
Escapes the input first (so raw HTML in user content cannot inject
markup), then inserts <br> tags and marks the result safe. Use this
instead of `| safe | nl2br`, which left an XSS hole.
"""
if not text:
return text
return text.replace('\n', '<br>\n')
return Markup(str(escape(text)).replace('\n', '<br>\n'))
@app.template_filter('timeago')
def timeago_filter(timestamp):
"""Format a unix timestamp as a relative time string ('3m ago', '5h ago',
'2d ago'), falling back to a date for older posts and 'Recently' for
missing/invalid input. Replaces the undefined `moment(...).fromNow()`
pattern that always rendered 'Recently'.
"""
if not timestamp:
return 'Recently'
try:
ts = float(timestamp)
except (TypeError, ValueError):
return 'Recently'
diff = time.time() - ts
if diff < 0:
return 'Recently'
if diff < 3600:
return f'{int(diff // 60)}m ago'
if diff < 86400:
return f'{int(diff // 3600)}h ago'
if diff < 604800:
return f'{int(diff // 86400)}d ago'
return time.strftime('%Y-%m-%d', time.localtime(ts))
@login_manager.user_loader
@@ -1062,8 +1118,8 @@ def password_reset(token):
password = request.form.get('password', '')
confirm_password = request.form.get('confirm_password', '')
if not password or len(password) < 6:
flash('Password must be at least 6 characters', 'error')
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: