- SSRF: block private/internal URLs in image cache fetch - SSRF: block private/internal URLs in RSS feed fetch (scheme guard) - SSRF: block private/internal URLs in CalDAV URL setting - Auth: require login for GET /api/images/<id> (was unauthenticated) - Auth: restrict Ollama model pull/delete to admin users only - Info disclosure: remove email from /api/users/search response - OAuth: skip email-based account linking when email_verified is false - Config: raise hard error on default SECRET_KEY when SECURE_COOKIES=true - Rate limit: document proxy header requirement; add startup warning - XSS: remove src/alt from global DOMPurify ADD_ATTR allowlist Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
399 lines
15 KiB
Python
399 lines
15 KiB
Python
"""Briefing API: RSS feeds, weather, and briefing configuration."""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
|
|
from quart import Blueprint, g, jsonify, request
|
|
from sqlalchemy import select
|
|
|
|
from fabledassistant.auth import login_required
|
|
from fabledassistant.models import async_session
|
|
from fabledassistant.models.conversation import Conversation, Message
|
|
from fabledassistant.models.rss_feed import RssFeed
|
|
from fabledassistant.services import rss as rss_svc
|
|
from fabledassistant.services import weather as weather_svc
|
|
from fabledassistant.services.briefing_conversations import (
|
|
get_or_create_today_conversation,
|
|
list_briefing_conversations,
|
|
post_message,
|
|
)
|
|
from fabledassistant.services.settings import get_setting, set_settings_batch
|
|
|
|
logger = logging.getLogger(__name__)
|
|
briefing_bp = Blueprint("briefing", __name__, url_prefix="/api/briefing")
|
|
|
|
_REQUIRE = login_required
|
|
|
|
|
|
# ── Config ────────────────────────────────────────────────────────────────────
|
|
|
|
@briefing_bp.route("/config", methods=["GET"])
|
|
@_REQUIRE
|
|
async def get_config():
|
|
raw = await get_setting(g.user.id, "briefing_config", "{}")
|
|
try:
|
|
config = json.loads(raw) if isinstance(raw, str) else (raw or {})
|
|
except Exception:
|
|
config = {}
|
|
return jsonify(config)
|
|
|
|
|
|
@briefing_bp.route("/config", methods=["PUT"])
|
|
@_REQUIRE
|
|
async def put_config():
|
|
data = await request.get_json()
|
|
await set_settings_batch(g.user.id, {"briefing_config": json.dumps(data)})
|
|
# Live-patch the scheduler using the stored user_timezone.
|
|
from fabledassistant.services.briefing_scheduler import update_user_schedule
|
|
tz_override = await get_setting(g.user.id, "user_timezone") or None
|
|
update_user_schedule(g.user.id, data, tz_override=tz_override)
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
# ── RSS Feeds ─────────────────────────────────────────────────────────────────
|
|
|
|
@briefing_bp.route("/feeds", methods=["GET"])
|
|
@_REQUIRE
|
|
async def list_feeds():
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(RssFeed).where(RssFeed.user_id == g.user.id).order_by(RssFeed.id)
|
|
)
|
|
feeds = list(result.scalars().all())
|
|
return jsonify([f.to_dict() for f in feeds])
|
|
|
|
|
|
@briefing_bp.route("/feeds", methods=["POST"])
|
|
@_REQUIRE
|
|
async def add_feed():
|
|
data = await request.get_json()
|
|
url = (data.get("url") or "").strip()
|
|
if not url:
|
|
return jsonify({"error": "url required"}), 400
|
|
scheme = url.split("://")[0].lower() if "://" in url else ""
|
|
if scheme not in ("http", "https"):
|
|
return jsonify({"error": "Feed URL must use http or https"}), 400
|
|
from fabledassistant.services.llm import _is_private_url
|
|
if _is_private_url(url):
|
|
return jsonify({"error": "Feed URL must not point to an internal address"}), 400
|
|
category = data.get("category") or None
|
|
|
|
async with async_session() as session:
|
|
# Prevent duplicates
|
|
existing = await session.execute(
|
|
select(RssFeed).where(RssFeed.user_id == g.user.id, RssFeed.url == url)
|
|
)
|
|
if existing.scalars().first():
|
|
return jsonify({"error": "Feed already added"}), 409
|
|
|
|
feed = RssFeed(user_id=g.user.id, url=url, title="", category=category)
|
|
session.add(feed)
|
|
await session.commit()
|
|
await session.refresh(feed)
|
|
feed_id = feed.id
|
|
|
|
# Fetch in background to auto-populate title and get initial items
|
|
asyncio.create_task(rss_svc.fetch_and_cache_feed(feed_id, url))
|
|
|
|
return jsonify({"id": feed_id, "url": url, "title": "", "category": category}), 201
|
|
|
|
|
|
@briefing_bp.route("/feeds/<int:feed_id>", methods=["DELETE"])
|
|
@_REQUIRE
|
|
async def delete_feed(feed_id: int):
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(RssFeed).where(RssFeed.id == feed_id, RssFeed.user_id == g.user.id)
|
|
)
|
|
feed = result.scalars().first()
|
|
if not feed:
|
|
return jsonify({"error": "Not found"}), 404
|
|
await session.delete(feed)
|
|
await session.commit()
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@briefing_bp.route("/feeds/refresh", methods=["POST"])
|
|
@_REQUIRE
|
|
async def refresh_feeds():
|
|
results = await rss_svc.refresh_all_feeds(g.user.id)
|
|
total_new = sum(results.values())
|
|
return jsonify({"feeds_refreshed": len(results), "new_items": total_new})
|
|
|
|
|
|
@briefing_bp.route("/feeds/recent", methods=["GET"])
|
|
@_REQUIRE
|
|
async def recent_items():
|
|
limit = min(int(request.args.get("limit", 20)), 100)
|
|
items = await rss_svc.get_recent_items(g.user.id, limit=limit)
|
|
return jsonify({"items": items})
|
|
|
|
|
|
# ── Weather ───────────────────────────────────────────────────────────────────
|
|
|
|
@briefing_bp.route("/weather", methods=["GET"])
|
|
@_REQUIRE
|
|
async def get_weather():
|
|
rows = await weather_svc.get_cached_weather_rows(g.user.id)
|
|
import json as _json
|
|
raw = await get_setting(g.user.id, "briefing_config", "{}")
|
|
try:
|
|
_cfg = _json.loads(raw) if isinstance(raw, str) else (raw or {})
|
|
temp_unit = _cfg.get("temp_unit", "C")
|
|
if temp_unit not in ("C", "F"):
|
|
temp_unit = "C"
|
|
except Exception:
|
|
temp_unit = "C"
|
|
cards = [
|
|
card for row in rows
|
|
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
|
|
]
|
|
return jsonify({"locations": cards, "temp_unit": temp_unit})
|
|
|
|
|
|
@briefing_bp.route("/weather/geocode", methods=["POST"])
|
|
@_REQUIRE
|
|
async def geocode_location():
|
|
data = await request.get_json()
|
|
query = (data.get("query") or "").strip()
|
|
if not query:
|
|
return jsonify({"error": "query required"}), 400
|
|
try:
|
|
lat, lon, label = await weather_svc.geocode(query)
|
|
return jsonify({"lat": lat, "lon": lon, "label": label})
|
|
except ValueError as e:
|
|
return jsonify({"error": str(e)}), 404
|
|
|
|
|
|
@briefing_bp.route("/weather/refresh", methods=["POST"])
|
|
@_REQUIRE
|
|
async def refresh_weather():
|
|
raw = await get_setting(g.user.id, "briefing_config", "{}")
|
|
try:
|
|
config = json.loads(raw) if isinstance(raw, str) else {}
|
|
except Exception:
|
|
config = {}
|
|
|
|
locations = config.get("locations", {})
|
|
refreshed = []
|
|
for key, loc in locations.items():
|
|
if not loc.get("lat") or not loc.get("lon"):
|
|
continue
|
|
try:
|
|
await weather_svc.refresh_location_cache(
|
|
user_id=g.user.id,
|
|
location_key=key,
|
|
location_label=loc.get("label", key),
|
|
lat=loc["lat"],
|
|
lon=loc["lon"],
|
|
)
|
|
refreshed.append(key)
|
|
except Exception:
|
|
logger.warning("Failed to refresh weather for %s", key, exc_info=True)
|
|
|
|
return jsonify({"refreshed": refreshed})
|
|
|
|
|
|
# ── Briefing Conversations ─────────────────────────────────────────────────────
|
|
|
|
@briefing_bp.route("/conversations", methods=["GET"])
|
|
@_REQUIRE
|
|
async def get_conversations():
|
|
convs = await list_briefing_conversations(g.user.id)
|
|
return jsonify({"conversations": convs})
|
|
|
|
|
|
@briefing_bp.route("/conversations/today", methods=["GET"])
|
|
@_REQUIRE
|
|
async def get_today_conversation():
|
|
model = await get_setting(g.user.id, "default_model", "")
|
|
conv = await get_or_create_today_conversation(g.user.id, model)
|
|
# Load messages
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Message)
|
|
.where(Message.conversation_id == conv.id)
|
|
.order_by(Message.created_at)
|
|
)
|
|
messages = [m.to_dict() for m in result.scalars().all()]
|
|
data = conv.to_dict()
|
|
data["messages"] = messages
|
|
return jsonify(data)
|
|
|
|
|
|
@briefing_bp.route("/conversations/<int:conv_id>/messages", methods=["GET"])
|
|
@_REQUIRE
|
|
async def get_conversation_messages(conv_id: int):
|
|
# Verify ownership
|
|
async with async_session() as session:
|
|
conv = await session.get(Conversation, conv_id)
|
|
if not conv or conv.user_id != g.user.id or conv.conversation_type != "briefing":
|
|
return jsonify({"error": "Not found"}), 404
|
|
result = await session.execute(
|
|
select(Message)
|
|
.where(Message.conversation_id == conv_id)
|
|
.order_by(Message.created_at)
|
|
)
|
|
messages = [m.to_dict() for m in result.scalars().all()]
|
|
return jsonify({"messages": messages})
|
|
|
|
|
|
@briefing_bp.route("/trigger", methods=["POST"])
|
|
@_REQUIRE
|
|
async def manual_trigger():
|
|
"""Dev/admin endpoint to manually trigger a briefing compilation."""
|
|
data = await request.get_json() or {}
|
|
slot = data.get("slot", "compilation")
|
|
if slot not in ("compilation", "morning", "midday", "afternoon"):
|
|
return jsonify({"error": "invalid slot"}), 400
|
|
|
|
from fabledassistant.services.briefing_pipeline import run_compilation
|
|
|
|
model = await get_setting(g.user.id, "default_model", "")
|
|
conv = await get_or_create_today_conversation(g.user.id, model)
|
|
text, metadata = await run_compilation(g.user.id, slot, model)
|
|
msg = await post_message(conv.id, "assistant", text, metadata=metadata)
|
|
return jsonify({"conversation_id": conv.id, "message_id": msg.id, "slot": slot})
|
|
|
|
|
|
# ── RSS Reactions ──────────────────────────────────────────────────────────────
|
|
|
|
@briefing_bp.route("/rss-reactions", methods=["POST"])
|
|
@_REQUIRE
|
|
async def upsert_rss_reaction():
|
|
"""Upsert a 👍/👎 reaction on an RSS item. Same reaction toggles off; opposite flips."""
|
|
data = await request.get_json()
|
|
rss_item_id = data.get("rss_item_id")
|
|
reaction = data.get("reaction")
|
|
|
|
if not rss_item_id or reaction not in ("up", "down"):
|
|
return jsonify({"error": "rss_item_id and reaction ('up'|'down') required"}), 400
|
|
|
|
from sqlalchemy import text as _text
|
|
|
|
async with async_session() as session:
|
|
# Ownership check: verify item belongs to a feed owned by this user
|
|
result = await session.execute(
|
|
_text("""
|
|
SELECT i.id FROM rss_items i
|
|
JOIN rss_feeds f ON f.id = i.feed_id
|
|
WHERE i.id = :item_id AND f.user_id = :uid
|
|
""").bindparams(item_id=rss_item_id, uid=g.user.id)
|
|
)
|
|
if result.first() is None:
|
|
return jsonify({"error": "Not found"}), 404
|
|
|
|
# Check existing reaction
|
|
existing = await session.execute(
|
|
_text("""
|
|
SELECT id, reaction FROM rss_item_reactions
|
|
WHERE user_id = :uid AND rss_item_id = :item_id
|
|
""").bindparams(uid=g.user.id, item_id=rss_item_id)
|
|
)
|
|
row = existing.first()
|
|
|
|
if row is None:
|
|
await session.execute(
|
|
_text("""
|
|
INSERT INTO rss_item_reactions (user_id, rss_item_id, reaction)
|
|
VALUES (:uid, :item_id, :reaction)
|
|
""").bindparams(uid=g.user.id, item_id=rss_item_id, reaction=reaction)
|
|
)
|
|
action = "created"
|
|
elif row.reaction == reaction:
|
|
# Toggle off (same reaction clicked again)
|
|
await session.execute(
|
|
_text("""
|
|
DELETE FROM rss_item_reactions
|
|
WHERE user_id = :uid AND rss_item_id = :item_id
|
|
""").bindparams(uid=g.user.id, item_id=rss_item_id)
|
|
)
|
|
action = "removed"
|
|
else:
|
|
# Flip to opposite reaction
|
|
await session.execute(
|
|
_text("""
|
|
UPDATE rss_item_reactions SET reaction = :reaction
|
|
WHERE user_id = :uid AND rss_item_id = :item_id
|
|
""").bindparams(reaction=reaction, uid=g.user.id, item_id=rss_item_id)
|
|
)
|
|
action = "updated"
|
|
|
|
await session.commit()
|
|
|
|
return jsonify({"ok": True, "action": action})
|
|
|
|
|
|
@briefing_bp.route("/rss-reactions/<int:item_id>", methods=["DELETE"])
|
|
@_REQUIRE
|
|
async def delete_rss_reaction(item_id: int):
|
|
"""Explicitly remove a reaction (useful for MCP/external API callers)."""
|
|
from sqlalchemy import text as _text
|
|
|
|
async with async_session() as session:
|
|
await session.execute(
|
|
_text("""
|
|
DELETE FROM rss_item_reactions
|
|
WHERE user_id = :uid AND rss_item_id = :item_id
|
|
""").bindparams(uid=g.user.id, item_id=item_id)
|
|
)
|
|
await session.commit()
|
|
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@briefing_bp.route("/news", methods=["GET"])
|
|
@_REQUIRE
|
|
async def list_news():
|
|
"""Return recent RSS articles with optional feed filter and pagination.
|
|
|
|
Query params:
|
|
days — lookback window (default 2, max 90)
|
|
limit — items per page (default 40, max 100)
|
|
offset — pagination offset (default 0)
|
|
feed_id — optional integer filter by feed
|
|
"""
|
|
from sqlalchemy import text as _text
|
|
|
|
days = min(int(request.args.get("days", 2)), 90)
|
|
limit = min(int(request.args.get("limit", 40)), 100)
|
|
offset = max(int(request.args.get("offset", 0)), 0)
|
|
feed_id = request.args.get("feed_id", type=int)
|
|
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
_text("""
|
|
SELECT
|
|
i.id, i.title, i.url, i.content, i.published_at,
|
|
i.topics, f.title AS feed_title,
|
|
r.reaction
|
|
FROM rss_items i
|
|
JOIN rss_feeds f ON f.id = i.feed_id
|
|
LEFT JOIN rss_item_reactions r
|
|
ON r.rss_item_id = i.id AND r.user_id = :uid
|
|
WHERE f.user_id = :uid
|
|
AND (CAST(:feed_id AS integer) IS NULL OR f.id = CAST(:feed_id AS integer))
|
|
AND COALESCE(i.published_at, i.fetched_at) >= NOW() - make_interval(days => :days)
|
|
ORDER BY COALESCE(i.published_at, i.fetched_at) DESC
|
|
LIMIT :limit OFFSET :offset
|
|
""").bindparams(uid=g.user.id, days=days, limit=limit,
|
|
offset=offset, feed_id=feed_id)
|
|
)
|
|
rows = result.mappings().all()
|
|
|
|
items = [
|
|
{
|
|
"id": r["id"],
|
|
"title": r["title"],
|
|
"url": r["url"],
|
|
"snippet": (r["content"] or "")[:300],
|
|
"published_at": r["published_at"].isoformat() if r["published_at"] else None,
|
|
"topics": r["topics"] or [],
|
|
"source": r["feed_title"],
|
|
"reaction": r["reaction"],
|
|
}
|
|
for r in rows
|
|
]
|
|
return jsonify({"items": items, "offset": offset, "limit": limit})
|