Compare commits

..

No commits in common. "6ff2fd03517dc0c17cf739c7a37f26382e4cef4d" and "5c6042c69240ff50c8d52c2e377e228572045de9" have entirely different histories.

3 changed files with 11 additions and 103 deletions

View file

@ -21,7 +21,6 @@ from datetime import datetime, timedelta, timezone
from decimal import Decimal from decimal import Decimal
from pathlib import Path from pathlib import Path
from typing import NoReturn from typing import NoReturn
from urllib.parse import urlparse
from uuid import UUID from uuid import UUID
import httpx import httpx
@ -44,7 +43,7 @@ from schemas import (
DashboardSummary, EntityCreate, EntityKind, EntityOut, DashboardSummary, EntityCreate, EntityKind, EntityOut,
EventCreate, EventOut, FireOut, NewsArticleOut, NewsMapItemOut, EventCreate, EventOut, FireOut, NewsArticleOut, NewsMapItemOut,
NewsSummaryOut, NewsTickerItemOut, NewsSummaryOut, NewsTickerItemOut,
FeedSourceCreate, FeedSourceOut, FeedSourceUpdate, FeedSourceCreate, FeedSourceOut,
KeyOut, KeyValueIn, KeyOut, KeyValueIn,
NewsModelsOut, SettingsIn, SettingsOut, NewsModelsOut, SettingsIn, SettingsOut,
SearchResult, SentimentSummary, SourceType, SearchResult, SentimentSummary, SourceType,
@ -52,8 +51,7 @@ from schemas import (
GeofenceCreate, GeofenceUpdate, GeofenceCreate, GeofenceUpdate,
) )
from ingestor import ingest_event, fetch_and_process from ingestor import ingest_event, fetch_and_process
from camera_scraper import is_public_url from sources import ingest_rss_feed, ingest_gdelt, ingest_earthquakes, ingest_social_signals
from sources import GDELT_API, ingest_rss_feed, ingest_gdelt, ingest_earthquakes, ingest_social_signals
from fire_sources import ingest_fires from fire_sources import ingest_fires
from keystore import KeyFormatError, delete_key, list_keys, set_key from keystore import KeyFormatError, delete_key, list_keys, set_key
from settings_store import SettingsError, get_app_settings, list_models, set_summary_model from settings_store import SettingsError, get_app_settings, list_models, set_summary_model
@ -368,22 +366,20 @@ async def create_source(payload: FeedSourceCreate):
@app.patch("/api/sources/{source_id}") @app.patch("/api/sources/{source_id}")
async def update_source(source_id: UUID, payload: FeedSourceUpdate): async def update_source(source_id: UUID, payload: dict):
"""Update a feed source (name/url/config/enabled only).""" """Update a feed source (e.g., toggle enabled)."""
values = payload.model_dump(exclude_unset=True)
async with async_session() as session: async with async_session() as session:
row = (await session.execute( row = (await session.execute(
select(feed_sources).where(feed_sources.c.id == source_id) select(feed_sources).where(feed_sources.c.id == source_id)
)).mappings().one_or_none() )).mappings().one_or_none()
if not row: if not row:
raise HTTPException(404, "Source not found") raise HTTPException(404, "Source not found")
if values: await session.execute(
await session.execute( feed_sources.update()
feed_sources.update() .where(feed_sources.c.id == source_id)
.where(feed_sources.c.id == source_id) .values(**payload)
.values(**values) )
) await session.commit()
await session.commit()
return {"ok": True} return {"ok": True}
@ -826,15 +822,9 @@ async def put_settings(payload: SettingsIn):
# ── Ingestion Triggers ─────────────────────────────────────────────────── # ── Ingestion Triggers ───────────────────────────────────────────────────
def _require_public_url(url: str, field: str) -> None:
if not is_public_url(url):
raise HTTPException(400, f"{field} is not a public URL")
@app.post("/api/ingest/rss") @app.post("/api/ingest/rss")
async def trigger_rss_ingest(feed_url: str, source_id: str | None = None): async def trigger_rss_ingest(feed_url: str, source_id: str | None = None):
"""Trigger RSS feed ingestion.""" """Trigger RSS feed ingestion."""
_require_public_url(feed_url, "feed_url")
count = await ingest_rss_feed(feed_url, source_id) count = await ingest_rss_feed(feed_url, source_id)
return {"status": "ok", "items_ingested": count} return {"status": "ok", "items_ingested": count}
@ -842,10 +832,6 @@ async def trigger_rss_ingest(feed_url: str, source_id: str | None = None):
@app.post("/api/ingest/gdelt") @app.post("/api/ingest/gdelt")
async def trigger_gdelt_ingest(query: str = "", max_articles: int = 50): async def trigger_gdelt_ingest(query: str = "", max_articles: int = 50):
"""Trigger GDELT data ingestion.""" """Trigger GDELT data ingestion."""
_require_public_url(GDELT_API, "GDELT target")
parsed = urlparse(query)
if parsed.scheme in ("http", "https") and parsed.hostname:
_require_public_url(query, "query")
count = await ingest_gdelt(query, max_articles) count = await ingest_gdelt(query, max_articles)
return {"status": "ok", "articles_ingested": count} return {"status": "ok", "articles_ingested": count}

View file

@ -7,7 +7,7 @@ from enum import Enum
from typing import Literal, Optional from typing import Literal, Optional
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, Field, field_validator
# ─── Enums ─────────────────────────────────────────────────────────────── # ─── Enums ───────────────────────────────────────────────────────────────
@ -63,17 +63,6 @@ class FeedSourceCreate(BaseModel):
config: Optional[dict] = None config: Optional[dict] = None
class FeedSourceUpdate(BaseModel):
"""PATCH /api/sources/{id} — only these keys may be set."""
model_config = ConfigDict(extra="forbid")
name: Optional[str] = None
url: Optional[str] = None
config: Optional[dict] = None
enabled: Optional[bool] = None
class FeedSourceOut(BaseModel): class FeedSourceOut(BaseModel):
id: UUID id: UUID
name: str name: str

View file

@ -1,67 +0,0 @@
"""SSRF guard on ingest triggers + PATCH /api/sources allowlist."""
from __future__ import annotations
import asyncio
import httpx
import pytest
from pydantic import ValidationError
from main import app
BASE = "http://test"
LINK_LOCAL_META = "http://169.254.169.254/latest/meta-data/"
LOOPBACK = "http://127.0.0.1/secret"
async def _req(method: str, path: str, **kw) -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url=BASE) as client:
return await client.request(method, path, **kw)
def test_rss_ingest_rejects_link_local_metadata_url(monkeypatch):
called = {"n": 0}
async def _boom(*_a, **_k):
called["n"] += 1
raise AssertionError("ingest_rss_feed must not run for a private URL")
monkeypatch.setattr("main.ingest_rss_feed", _boom)
resp = asyncio.run(_req("POST", "/api/ingest/rss", params={"feed_url": LINK_LOCAL_META}))
assert resp.status_code == 400
assert called["n"] == 0
def test_gdelt_ingest_rejects_private_query_url(monkeypatch):
called = {"n": 0}
async def _boom(*_a, **_k):
called["n"] += 1
raise AssertionError("ingest_gdelt must not run for a private URL query")
monkeypatch.setattr("main.ingest_gdelt", _boom)
resp = asyncio.run(_req("POST", "/api/ingest/gdelt", params={"query": LOOPBACK}))
assert resp.status_code == 400
assert called["n"] == 0
def test_update_source_rejects_unknown_fields():
sid = "00000000-0000-0000-0000-000000000001"
resp = asyncio.run(_req("PATCH", f"/api/sources/{sid}", json={"enabled": True, "source_type": "rss"}))
assert resp.status_code == 422
def test_feed_source_update_allowlist_only():
from schemas import FeedSourceUpdate
payload = FeedSourceUpdate(name="n", url="https://example.com/rss", config={"k": 1}, enabled=False)
assert payload.model_dump(exclude_unset=True) == {
"name": "n",
"url": "https://example.com/rss",
"config": {"k": 1},
"enabled": False,
}
with pytest.raises(ValidationError):
FeedSourceUpdate.model_validate({"enabled": True, "id": "00000000-0000-0000-0000-000000000001"})