refactor/phase-0-bugfixes #30
76
app.py
76
app.py
@@ -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
|
||||||
|
from filter_pipeline import FilterEngine
|
||||||
|
|
||||||
|
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.init_app(app)
|
||||||
polling_service.start()
|
polling_service.start()
|
||||||
|
_services_started = True
|
||||||
# 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")
|
|
||||||
|
|
||||||
# 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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,8 +24,9 @@ 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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user