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 pathlib import Path
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from flask import Flask, render_template, request, redirect, url_for, flash, send_from_directory, abort, session, jsonify 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 flask_login import LoginManager, login_user, logout_user, login_required, current_user
from dotenv import load_dotenv from dotenv import load_dotenv
from functools import lru_cache from functools import lru_cache
@@ -81,15 +82,36 @@ login_manager.login_message = 'Please log in to access this page.'
# Initialize user service # Initialize user service
user_service = UserService() 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 from polling_service import polling_service
polling_service.init_app(app)
polling_service.start()
# Initialize filter engine
from filter_pipeline import FilterEngine 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 # Initialize OAuth for Auth0
oauth = OAuth(app) oauth = OAuth(app)
@@ -114,6 +136,10 @@ def _is_safe_filterset(filterset):
"""Validate filterset name for security""" """Validate filterset name for security"""
if not filterset or not isinstance(filterset, str): if not filterset or not isinstance(filterset, str):
return False 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 # Check against available filtersets from filter_engine
allowed_filtersets = set(filter_engine.get_available_filtersets()) allowed_filtersets = set(filter_engine.get_available_filtersets())
return filterset in allowed_filtersets and re.match(r'^[a-zA-Z0-9_-]+$', filterset) 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 # Add custom Jinja filters
@app.template_filter('nl2br') @app.template_filter('nl2br')
def nl2br_filter(text): 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: if not text:
return 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 @login_manager.user_loader
@@ -1062,8 +1118,8 @@ def password_reset(token):
password = request.form.get('password', '') password = request.form.get('password', '')
confirm_password = request.form.get('confirm_password', '') confirm_password = request.form.get('confirm_password', '')
if not password or len(password) < 6: if not password or len(password) < MIN_PASSWORD_LENGTH:
flash('Password must be at least 6 characters', 'error') flash(f'Password must be at least {MIN_PASSWORD_LENGTH} characters', 'error')
return render_template('password_reset.html') return render_template('password_reset.html')
if password != confirm_password: if password != confirm_password:

View File

@@ -184,10 +184,16 @@ class FilterEngine:
if self.config.is_ai_enabled(): if self.config.is_ai_enabled():
self._init_stages() self._init_stages()
# If AI is disabled but filterset requires it, fall back to no_filter # If AI is disabled but the filterset requires it, do NOT silently pass
# everything as no_filter. Pass the posts through (so the feed is not
# blanked) but mark every result as FAILED with an explicit error so the
# degradation is observable, not silent.
if not self.config.is_ai_enabled() and filterset_name != 'no_filter': if not self.config.is_ai_enabled() and filterset_name != 'no_filter':
logger.warning(f"AI disabled but '{filterset_name}' requires AI - falling back to 'no_filter'") logger.warning(
return self._process_no_filter(posts) f"AI disabled but filterset '{filterset_name}' requires AI - "
f"passing posts through unfiltered with FAILED status"
)
return self._process_ai_disabled(filterset_name, posts)
# Get pipeline stages for this filterset # Get pipeline stages for this filterset
stage_names = self._get_stages_for_filterset(filterset_name) stage_names = self._get_stages_for_filterset(filterset_name)
@@ -218,6 +224,28 @@ class FilterEngine:
return results return results
def _process_ai_disabled(self, filterset_name: str, posts: List[Dict[str, Any]]) -> List[FilterResult]:
"""Pass posts through unfiltered when the requested filterset needs AI
but AI is disabled. Unlike no_filter, every result is marked FAILED with
an explicit error so the degradation is observable rather than silent.
"""
results = []
for post in posts:
result = FilterResult(
post_uuid=post.get('uuid', ''),
passed=True, # do not blank the feed
score=0.5, # neutral score
categories=[],
tags=[],
filterset_name=filterset_name,
processed_at=datetime.now(),
status=ProcessingStatus.FAILED,
error=f"AI disabled: filterset '{filterset_name}' requires AI; passed through unfiltered"
)
results.append(result)
return results
def _get_stages_for_filterset(self, filterset_name: str) -> List[str]: def _get_stages_for_filterset(self, filterset_name: str) -> List[str]:
"""Get pipeline stages to run for a filterset""" """Get pipeline stages to run for a filterset"""
filterset = self.config.get_filterset(filterset_name) filterset = self.config.get_filterset(filterset_name)

View File

@@ -5,7 +5,7 @@ Migration script to create the bookmarks table.
import os import os
import sys import sys
from database import init_db from database import init_db, db
from flask import Flask from flask import Flask
# Add the current directory to Python path # Add the current directory to Python path
@@ -24,12 +24,13 @@ def main():
app = create_app() app = create_app()
with app.app_context(): with app.app_context():
# Initialize database # Initialize database (init_db binds the shared `db` extension; it
db = init_db(app) # returns None, so use the module-level `db` directly).
init_db(app)
# Import models to register them # Import models to register them
from models import User, Session, PollSource, PollLog, Bookmark from models import User, Session, PollSource, PollLog, Bookmark
# Create all tables (will only create missing ones) # Create all tables (will only create missing ones)
db.create_all() db.create_all()

View File

@@ -31,7 +31,7 @@
<span class="post-source">{{ post.source_display if post.source_display else ('r/' + post.source if post.platform == 'reddit' else post.source) }}</span> <span class="post-source">{{ post.source_display if post.source_display else ('r/' + post.source if post.platform == 'reddit' else post.source) }}</span>
<span class="post-separator"></span> <span class="post-separator"></span>
{% endif %} {% endif %}
<span class="post-time">{{ moment(post.timestamp).fromNow() if moment else 'Recently' }}</span> <span class="post-time" data-timestamp="{{ post.timestamp }}">{{ post.timestamp|timeago }}</span>
{% if post.url and not post.url.startswith('/') %} {% if post.url and not post.url.startswith('/') %}
<span class="external-link-indicator">🔗</span> <span class="external-link-indicator">🔗</span>
{% endif %} {% endif %}
@@ -48,7 +48,7 @@
{% if post.content %} {% if post.content %}
<div class="post-content"> <div class="post-content">
{{ post.content | safe | nl2br }} {{ post.content | nl2br }}
</div> </div>
{% endif %} {% endif %}
@@ -101,10 +101,10 @@
<div class="comment-header"> <div class="comment-header">
<span class="comment-author">{{ comment.author }}</span> <span class="comment-author">{{ comment.author }}</span>
<span class="comment-separator"></span> <span class="comment-separator"></span>
<span class="comment-time">{{ moment(comment.timestamp).fromNow() if moment else 'Recently' }}</span> <span class="comment-time" data-timestamp="{{ comment.timestamp }}">{{ comment.timestamp|timeago }}</span>
</div> </div>
<div class="comment-content"> <div class="comment-content">
{{ comment.content | safe | nl2br }} {{ comment.content | nl2br }}
</div> </div>
<div class="comment-footer"> <div class="comment-footer">
<div class="comment-score"> <div class="comment-score">