Coverage for src/local_deep_research/web/routes/news_routes.py: 100%
151 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
1"""
2Flask routes for news API endpoints.
3"""
5import uuid
6from functools import wraps
8from flask import Blueprint, jsonify, request, session
9from loguru import logger
11from ...news import api as news_api
12from ...news.exceptions import NewsAPIException
13from ...utilities.url_utils import is_safe_custom_llm_endpoint
14from ...security.decorators import require_json_body
15from ..auth.decorators import login_required
16from ...security.rate_limiter import limiter
19def _reject_custom_endpoint(custom_endpoint):
20 """Return a Flask 400 response if ``custom_endpoint`` fails SSRF
21 validation, else None.
23 Rejects cloud-metadata / link-local targets at the request boundary as
24 fail-fast defense-in-depth (the OpenAI-compatible provider re-validates
25 the same URL via assert_base_url_safe before the client is built).
26 Private IPs and localhost are allowed because local LLMs live there;
27 scheme-less endpoints are normalized exactly as the provider does.
28 """
29 if is_safe_custom_llm_endpoint(custom_endpoint):
30 return None
31 return (
32 jsonify({"success": False, "error": "Invalid custom endpoint URL"}),
33 400,
34 )
37def _is_valid_uuid(value: str) -> bool:
38 """Return True if ``value`` parses as a UUID, False otherwise.
40 Used to validate path/query subscription_id parameters before they
41 reach the LIKE-pattern queries in ``news/api.py``. Without this
42 check, a request like ``?subscription_id=%`` would expand the
43 LIKE filter and match arbitrary subscriptions (enumeration vector,
44 though not data exfiltration since user-DB isolation still applies).
45 """
46 try:
47 uuid.UUID(str(value))
48 except (ValueError, AttributeError, TypeError):
49 return False
50 return True
53# Create blueprint
54bp = Blueprint("news_api", __name__, url_prefix="/api/news")
56# NOTE: Routes use session["username"] (not .get()) intentionally.
57# @login_required guarantees the key exists; direct access fails fast
58# if the decorator is ever removed.
60# Shared rate limits for POST endpoints
61_news_create_limit = limiter.shared_limit("10 per minute", scope="news_create")
62_news_research_limit = limiter.shared_limit(
63 "5 per minute", scope="news_research"
64)
65_news_feedback_limit = limiter.shared_limit(
66 "30 per minute", scope="news_feedback"
67)
68_news_preferences_limit = limiter.shared_limit(
69 "10 per minute", scope="news_preferences"
70)
73def handle_api_errors(f):
74 """Decorator to handle API errors consistently across news endpoints."""
76 @wraps(f)
77 def wrapper(*args, **kwargs):
78 try:
79 return f(*args, **kwargs)
80 except NewsAPIException:
81 raise
82 except Exception:
83 logger.exception("Unexpected error in {}", f.__name__)
84 return jsonify({"error": "Internal server error"}), 500
86 return wrapper
89@bp.errorhandler(NewsAPIException)
90def handle_news_api_exception(error: NewsAPIException):
91 """Handle NewsAPIException and convert to JSON response."""
92 logger.error(
93 "News API error: {} (status {})", error.error_code, error.status_code
94 )
95 return jsonify(error.to_dict()), error.status_code
98@bp.route("/feed", methods=["GET"])
99@login_required
100@handle_api_errors
101def get_news_feed():
102 """Get personalized news feed."""
103 user_id = session["username"]
104 limit = request.args.get("limit", 20, type=int)
105 limit = max(1, min(limit, 200))
106 use_cache = request.args.get("use_cache", "true").lower() == "true"
107 focus = request.args.get("focus")
108 search_strategy = request.args.get("search_strategy")
109 subscription_id = request.args.get("subscription_id")
111 if subscription_id and not _is_valid_uuid(subscription_id):
112 return jsonify(
113 {
114 "success": False,
115 "error": "Invalid subscription_id",
116 }
117 ), 400
119 result = news_api.get_news_feed(
120 user_id=user_id,
121 limit=limit,
122 use_cache=use_cache,
123 focus=focus,
124 search_strategy=search_strategy,
125 subscription_id=subscription_id,
126 )
128 return jsonify(result)
131@bp.route("/subscriptions", methods=["GET"])
132@login_required
133@handle_api_errors
134def get_subscriptions():
135 """Get all subscriptions for the current user."""
136 user_id = session["username"]
137 result = news_api.get_subscriptions(user_id)
138 return jsonify(result)
141@bp.route("/subscriptions", methods=["POST"])
142@login_required
143@handle_api_errors
144@_news_create_limit
145@require_json_body()
146def create_subscription():
147 """Create a new subscription."""
148 user_id = session["username"]
149 data = request.get_json()
151 bad_endpoint = _reject_custom_endpoint(data.get("custom_endpoint"))
152 if bad_endpoint is not None:
153 return bad_endpoint
155 result = news_api.create_subscription(
156 user_id=user_id,
157 query=data.get("query"),
158 subscription_type=data.get("type", "search"),
159 refresh_minutes=data.get("refresh_minutes"),
160 source_research_id=data.get("source_research_id"),
161 model_provider=data.get("model_provider"),
162 model=data.get("model"),
163 search_strategy=data.get("search_strategy"),
164 custom_endpoint=data.get("custom_endpoint"),
165 name=data.get("name"),
166 folder_id=data.get("folder_id"),
167 is_active=data.get("is_active", True),
168 search_engine=data.get("search_engine"),
169 search_iterations=data.get("search_iterations"),
170 questions_per_iteration=data.get("questions_per_iteration"),
171 )
173 return jsonify(result), 201
176@bp.route("/subscriptions/<subscription_id>", methods=["GET"])
177@login_required
178@handle_api_errors
179def get_subscription(subscription_id):
180 """Get a single subscription by ID."""
181 result = news_api.get_subscription(subscription_id)
182 return jsonify(result)
185@bp.route("/subscriptions/<subscription_id>", methods=["PUT", "PATCH"])
186@login_required
187@handle_api_errors
188@require_json_body()
189def update_subscription(subscription_id):
190 """Update an existing subscription."""
191 data = request.get_json()
192 bad_endpoint = _reject_custom_endpoint(data.get("custom_endpoint"))
193 if bad_endpoint is not None:
194 return bad_endpoint
195 result = news_api.update_subscription(subscription_id, data)
196 return jsonify(result)
199@bp.route("/subscriptions/<subscription_id>", methods=["DELETE"])
200@login_required
201@handle_api_errors
202def delete_subscription(subscription_id):
203 """Delete a subscription."""
204 result = news_api.delete_subscription(subscription_id)
205 return jsonify(result)
208@bp.route("/subscriptions/<subscription_id>/history", methods=["GET"])
209@login_required
210@handle_api_errors
211def get_subscription_history(subscription_id):
212 """Get research history for a specific subscription."""
213 if not _is_valid_uuid(subscription_id):
214 return jsonify(
215 {
216 "success": False,
217 "error": "Invalid subscription_id",
218 }
219 ), 400
220 limit = request.args.get("limit", 20, type=int)
221 limit = max(1, min(limit, 200))
222 result = news_api.get_subscription_history(subscription_id, limit)
223 return jsonify(result)
226@bp.route("/feedback", methods=["POST"])
227@login_required
228@handle_api_errors
229@_news_feedback_limit
230@require_json_body()
231def submit_feedback():
232 """Submit feedback (vote) for a news card."""
233 user_id = session["username"]
234 data = request.get_json()
235 card_id = data.get("card_id")
236 vote = data.get("vote")
238 if not card_id or vote not in ["up", "down"]:
239 return jsonify({"error": "Invalid request"}), 400
241 result = news_api.submit_feedback(card_id, user_id, vote)
242 return jsonify(result)
245@bp.route("/research", methods=["POST"])
246@login_required
247@handle_api_errors
248@_news_research_limit
249@require_json_body()
250def research_news_item():
251 """Perform deeper research on a news item."""
252 data = request.get_json()
253 card_id = data.get("card_id")
254 depth = data.get("depth", "quick")
256 if not card_id:
257 return jsonify({"error": "card_id is required"}), 400
259 result = news_api.research_news_item(card_id, depth)
260 return jsonify(result)
263@bp.route("/preferences", methods=["POST"])
264@login_required
265@handle_api_errors
266@_news_preferences_limit
267@require_json_body()
268def save_preferences():
269 """Save user preferences for news."""
270 user_id = session["username"]
271 preferences = request.get_json()
272 result = news_api.save_news_preferences(user_id, preferences)
273 return jsonify(result)
276@bp.route("/categories", methods=["GET"])
277@login_required
278@handle_api_errors
279def get_categories():
280 """Get available news categories with counts."""
281 result = news_api.get_news_categories()
282 return jsonify(result)