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:
78
app.py
78
app.py
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user