diff --git a/app.py b/app.py
index 1ec8c77..b1c221a 100644
--- a/app.py
+++ b/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
tags"""
+ """Convert newlines to
tags.
+
+ Escapes the input first (so raw HTML in user content cannot inject
+ markup), then inserts
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', '
\n')
+ return Markup(str(escape(text)).replace('\n', '
\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:
diff --git a/filter_pipeline/engine.py b/filter_pipeline/engine.py
index e79e5a4..d6220e9 100644
--- a/filter_pipeline/engine.py
+++ b/filter_pipeline/engine.py
@@ -184,10 +184,16 @@ class FilterEngine:
if self.config.is_ai_enabled():
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':
- logger.warning(f"AI disabled but '{filterset_name}' requires AI - falling back to 'no_filter'")
- return self._process_no_filter(posts)
+ logger.warning(
+ 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
stage_names = self._get_stages_for_filterset(filterset_name)
@@ -218,6 +224,28 @@ class FilterEngine:
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]:
"""Get pipeline stages to run for a filterset"""
filterset = self.config.get_filterset(filterset_name)
diff --git a/migrate_bookmarks.py b/migrate_bookmarks.py
index bcb6352..4c5ac6e 100644
--- a/migrate_bookmarks.py
+++ b/migrate_bookmarks.py
@@ -5,7 +5,7 @@ Migration script to create the bookmarks table.
import os
import sys
-from database import init_db
+from database import init_db, db
from flask import Flask
# Add the current directory to Python path
@@ -24,12 +24,13 @@ def main():
app = create_app()
with app.app_context():
- # Initialize database
- db = init_db(app)
-
+ # Initialize database (init_db binds the shared `db` extension; it
+ # returns None, so use the module-level `db` directly).
+ init_db(app)
+
# Import models to register them
from models import User, Session, PollSource, PollLog, Bookmark
-
+
# Create all tables (will only create missing ones)
db.create_all()
diff --git a/templates/post_detail.html b/templates/post_detail.html
index 29d70b8..41ab284 100644
--- a/templates/post_detail.html
+++ b/templates/post_detail.html
@@ -31,7 +31,7 @@
{{ post.source_display if post.source_display else ('r/' + post.source if post.platform == 'reddit' else post.source) }}
•
{% endif %}
- {{ moment(post.timestamp).fromNow() if moment else 'Recently' }}
+ {{ post.timestamp|timeago }}
{% if post.url and not post.url.startswith('/') %}
🔗
{% endif %}
@@ -48,7 +48,7 @@
{% if post.content %}