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

1""" 

2Flask routes for news API endpoints. 

3""" 

4 

5import uuid 

6from functools import wraps 

7 

8from flask import Blueprint, jsonify, request, session 

9from loguru import logger 

10 

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 

17 

18 

19def _reject_custom_endpoint(custom_endpoint): 

20 """Return a Flask 400 response if ``custom_endpoint`` fails SSRF 

21 validation, else None. 

22 

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 ) 

35 

36 

37def _is_valid_uuid(value: str) -> bool: 

38 """Return True if ``value`` parses as a UUID, False otherwise. 

39 

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 

51 

52 

53# Create blueprint 

54bp = Blueprint("news_api", __name__, url_prefix="/api/news") 

55 

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. 

59 

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) 

71 

72 

73def handle_api_errors(f): 

74 """Decorator to handle API errors consistently across news endpoints.""" 

75 

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 

85 

86 return wrapper 

87 

88 

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 

96 

97 

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") 

110 

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 

118 

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 ) 

127 

128 return jsonify(result) 

129 

130 

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) 

139 

140 

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() 

150 

151 bad_endpoint = _reject_custom_endpoint(data.get("custom_endpoint")) 

152 if bad_endpoint is not None: 

153 return bad_endpoint 

154 

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 ) 

172 

173 return jsonify(result), 201 

174 

175 

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) 

183 

184 

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) 

197 

198 

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) 

206 

207 

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) 

224 

225 

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") 

237 

238 if not card_id or vote not in ["up", "down"]: 

239 return jsonify({"error": "Invalid request"}), 400 

240 

241 result = news_api.submit_feedback(card_id, user_id, vote) 

242 return jsonify(result) 

243 

244 

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") 

255 

256 if not card_id: 

257 return jsonify({"error": "card_id is required"}), 400 

258 

259 result = news_api.research_news_item(card_id, depth) 

260 return jsonify(result) 

261 

262 

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) 

274 

275 

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)