From 1878dff520ee2e424b088a963c8f6417f5106ce9 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Sat, 11 Apr 2026 12:34:11 -0500 Subject: feat: harden contribution api for production use --- README.md | 16 +++++++--------- src/srht_contrib/api/routes_contributions.py | 4 ++-- tests/test_contributions_api.py | 8 ++++++-- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index d9baf54..b05e71a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ The current V1 is intentionally narrow and production-oriented: - polling-based ingestion - complete `todo.sr.ht` ingestion path - practical `git.sr.ht` commit ingestion for tracked repositories -- API-key protection for `/api/*` +- public read-only contribution endpoints plus API-key protection for mutating/admin routes - Alembic-managed schema migrations ## What It Does @@ -75,7 +75,7 @@ Tracked git repositories are persisted in the `tracked_repositories` table and s Environment variables: -- `API_KEY`: required header token for all `/api/*` routes via `X-API-Key` +- `API_KEY`: required header token for mutating/admin routes via `X-API-Key` - `ENABLE_SCHEDULER`: defaults to `false`; enables in-process polling when set to `true` - `SRHT_TOKEN`: bearer token for SourceHut GraphQL - `TODO_SRHT_ENDPOINT`: defaults to `https://todo.sr.ht/query` @@ -189,15 +189,13 @@ Response: ### Contribution Calendar by Year ```bash -curl "http://127.0.0.1:8000/api/contributions/~ccleberg?year=2026" \ - -H "X-API-Key: replace-me" +curl "http://127.0.0.1:8000/api/contributions/~ccleberg?year=2026" ``` ### Contribution Calendar by Date Range ```bash -curl "http://127.0.0.1:8000/api/contributions/~ccleberg?from=2026-01-01&to=2026-03-30" \ - -H "X-API-Key: replace-me" +curl "http://127.0.0.1:8000/api/contributions/~ccleberg?from=2026-01-01&to=2026-03-30" ``` Example response: @@ -218,8 +216,7 @@ Example response: ### Contribution Stats ```bash -curl "http://127.0.0.1:8000/api/contributions/~ccleberg/stats?year=2026" \ - -H "X-API-Key: replace-me" +curl "http://127.0.0.1:8000/api/contributions/~ccleberg/stats?year=2026" ``` Example response: @@ -292,7 +289,8 @@ pytest Covered areas: - health endpoint -- API key enforcement +- public read-only contribution endpoints +- API key enforcement for mutating/admin routes - calendar aggregation - zero-filled ranges - stats calculations diff --git a/src/srht_contrib/api/routes_contributions.py b/src/srht_contrib/api/routes_contributions.py index 0682aa6..c682553 100644 --- a/src/srht_contrib/api/routes_contributions.py +++ b/src/srht_contrib/api/routes_contributions.py @@ -13,7 +13,7 @@ from srht_contrib.services.srht_client import SourceHutClientError from srht_contrib.utils.dates import parse_date, year_bounds from srht_contrib.utils.identity import ActorIdentityResolver -router = APIRouter(prefix="/api/contributions", tags=["contributions"], dependencies=[Depends(require_api_key)]) +router = APIRouter(prefix="/api/contributions", tags=["contributions"]) def _resolve_range(year: int | None, from_date: str | None, to_date: str | None) -> tuple[date, date]: @@ -63,7 +63,7 @@ def get_contribution_stats( return ContributionAggregator().build_stats(db, canonical_actor, start, end) -@router.post("/poll", response_model=PollResponse) +@router.post("/poll", response_model=PollResponse, dependencies=[Depends(require_api_key)]) def trigger_manual_poll( actor: str, poller: PollerService = Depends(get_poller), diff --git a/tests/test_contributions_api.py b/tests/test_contributions_api.py index d90aceb..e221602 100644 --- a/tests/test_contributions_api.py +++ b/tests/test_contributions_api.py @@ -6,14 +6,18 @@ from srht_contrib.main import create_app from srht_contrib.models import ContributionEvent -def test_api_routes_require_api_key(settings, db_engine, session_factory) -> None: +def test_read_only_contribution_routes_are_public_and_write_routes_require_api_key(settings, db_engine, session_factory) -> None: app = create_app(settings, engine=db_engine, session_factory=session_factory) with TestClient(app) as open_client: response = open_client.get("/health") + public_contributions = open_client.get("/api/contributions/~ccleberg?from=2026-03-28&to=2026-03-30") + public_stats = open_client.get("/api/contributions/~ccleberg/stats?from=2026-03-28&to=2026-03-30") assert response.status_code == 200 + assert public_contributions.status_code == 200 + assert public_stats.status_code == 200 with TestClient(app) as unauthorized: - unauthorized_response = unauthorized.get("/api/contributions/~ccleberg?from=2026-03-28&to=2026-03-30") + unauthorized_response = unauthorized.post("/api/contributions/poll?actor=~ccleberg") assert unauthorized_response.status_code == 401 with TestClient(app) as invalid: -- cgit v1.2.3