diff --git a/plugins/limnoria/NtrPlaylist/plugin.py b/plugins/limnoria/NtrPlaylist/plugin.py index c1b5c3c..12660fa 100644 --- a/plugins/limnoria/NtrPlaylist/plugin.py +++ b/plugins/limnoria/NtrPlaylist/plugin.py @@ -28,9 +28,12 @@ class ApiError(Exception): super().__init__(f"{status_code}: {detail}") -def _api_get(base_url: str, path: str) -> dict: +def _api_get(base_url: str, path: str, token: str = "") -> dict: url = f"{base_url.rstrip('/')}{path}" - req = urllib.request.Request(url, headers={"Accept": "application/json"}) + headers = {"Accept": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + req = urllib.request.Request(url, headers=headers) try: with urllib.request.urlopen(req, timeout=10) as resp: raw = resp.read().decode() @@ -234,8 +237,9 @@ class NtrPlaylist(callbacks.Plugin): if match: position = match.group(1) base_url = self.registryValue("apiBaseUrl") + token = self.registryValue("adminToken") try: - data = _api_get(base_url, f"/playlist/{position}") + data = _api_get(base_url, f"/playlist/{position}", token) irc.reply(format_track(data)) except ApiError as exc: LOGGER.warning("API error for !%s: %s", position, exc) @@ -260,8 +264,9 @@ class NtrPlaylist(callbacks.Plugin): irc.reply("Usage: !song ") return base_url = self.registryValue("apiBaseUrl") + token = self.registryValue("adminToken") try: - data = _api_get(base_url, f"/shows/by-episode/{episode}") + data = _api_get(base_url, f"/shows/by-episode/{episode}", token) except ApiError as exc: LOGGER.warning("API error for !song %s %s: %s", episode, position, exc) irc.reply(exc.detail) @@ -280,6 +285,7 @@ class NtrPlaylist(callbacks.Plugin): Returns the playlist for the current show, or a specific episode. """ base_url = self.registryValue("apiBaseUrl") + token = self.registryValue("adminToken") if text and text.strip(): try: episode = int(text.strip()) @@ -287,14 +293,14 @@ class NtrPlaylist(callbacks.Plugin): irc.reply("Usage: !playlist [episode]") return try: - data = _api_get(base_url, f"/shows/by-episode/{episode}") + data = _api_get(base_url, f"/shows/by-episode/{episode}", token) except ApiError as exc: LOGGER.warning("API error for playlist: %s", exc) irc.reply(exc.detail) return else: try: - data = _api_get(base_url, "/playlist") + data = _api_get(base_url, "/playlist", token) except ApiError as exc: LOGGER.warning("API error for playlist: %s", exc) irc.reply(exc.detail) @@ -316,8 +322,9 @@ class NtrPlaylist(callbacks.Plugin): irc.reply("Usage: !lastshow ") return base_url = self.registryValue("apiBaseUrl") + token = self.registryValue("adminToken") try: - shows = _api_get(base_url, "/shows?limit=2") + shows = _api_get(base_url, "/shows?limit=2", token) except ApiError as exc: LOGGER.warning("API error for lastshow: %s", exc) irc.reply(exc.detail) @@ -327,7 +334,7 @@ class NtrPlaylist(callbacks.Plugin): return prev_show_id = shows[1]["id"] try: - data = _api_get(base_url, f"/shows/{prev_show_id}") + data = _api_get(base_url, f"/shows/{prev_show_id}", token) except ApiError as exc: LOGGER.warning("API error for lastshow show %s: %s", prev_show_id, exc) irc.reply(exc.detail) diff --git a/plugins/sopel/ntr_playlist.py b/plugins/sopel/ntr_playlist.py index fbe8c6c..c250cb0 100644 --- a/plugins/sopel/ntr_playlist.py +++ b/plugins/sopel/ntr_playlist.py @@ -143,9 +143,12 @@ class ApiError(Exception): super().__init__(f"{status_code}: {detail}") -def _api_get(base_url: str, path: str) -> dict: +def _api_get(base_url: str, path: str, token: str = "") -> dict: url = f"{base_url.rstrip('/')}{path}" - req = urllib.request.Request(url, headers={"Accept": "application/json"}) + headers = {"Accept": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + req = urllib.request.Request(url, headers=headers) try: with urllib.request.urlopen(req, timeout=10) as resp: raw = resp.read().decode() @@ -235,9 +238,10 @@ def _is_admin(bot, nick: str) -> bool: @plugin.rule(r"^!(\d+)$") def ntr_playlist_position(bot, trigger): base_url = bot.settings.ntr_playlist.api_base_url + token = bot.settings.ntr_playlist.admin_token position = trigger.group(1) try: - data = _api_get(base_url, f"/playlist/{position}") + data = _api_get(base_url, f"/playlist/{position}", token) bot.say(format_track(data)) except ApiError as e: LOGGER.warning("API error for !%s: %s", position, e) @@ -261,8 +265,9 @@ def ntr_song(bot, trigger): bot.say("Usage: !song ") return base_url = bot.settings.ntr_playlist.api_base_url + token = bot.settings.ntr_playlist.admin_token try: - data = _api_get(base_url, f"/shows/by-episode/{episode}") + data = _api_get(base_url, f"/shows/by-episode/{episode}", token) except ApiError as e: LOGGER.warning("API error for !song %s %s: %s", episode, position, e) bot.say(e.detail) @@ -279,6 +284,7 @@ def ntr_song(bot, trigger): def ntr_playlist(bot, trigger): raw = trigger.group(2) base_url = bot.settings.ntr_playlist.api_base_url + token = bot.settings.ntr_playlist.admin_token if raw and raw.strip(): try: episode = int(raw.strip()) @@ -286,14 +292,14 @@ def ntr_playlist(bot, trigger): bot.say("Usage: !playlist [episode]") return try: - data = _api_get(base_url, f"/shows/by-episode/{episode}") + data = _api_get(base_url, f"/shows/by-episode/{episode}", token) except ApiError as e: LOGGER.warning("API error for !playlist: %s", e) bot.say(e.detail) return else: try: - data = _api_get(base_url, "/playlist") + data = _api_get(base_url, "/playlist", token) except ApiError as e: LOGGER.warning("API error for !playlist: %s", e) bot.say(e.detail) @@ -313,8 +319,9 @@ def ntr_lastshow(bot, trigger): bot.say("Usage: !lastshow ") return base_url = bot.settings.ntr_playlist.api_base_url + token = bot.settings.ntr_playlist.admin_token try: - shows = _api_get(base_url, "/shows?limit=2") + shows = _api_get(base_url, "/shows?limit=2", token) except ApiError as e: LOGGER.warning("API error for !lastshow: %s", e) bot.say(e.detail) @@ -324,7 +331,7 @@ def ntr_lastshow(bot, trigger): return prev_show_id = shows[1]["id"] try: - data = _api_get(base_url, f"/shows/{prev_show_id}") + data = _api_get(base_url, f"/shows/{prev_show_id}", token) except ApiError as e: LOGGER.warning("API error for !lastshow show %s: %s", prev_show_id, e) bot.say(e.detail) diff --git a/src/ntr_fetcher/api.py b/src/ntr_fetcher/api.py index 04af4d7..e32d947 100644 --- a/src/ntr_fetcher/api.py +++ b/src/ntr_fetcher/api.py @@ -69,7 +69,7 @@ def create_app( } @app.get("/playlist") - def playlist(): + def playlist(_=Depends(_require_admin)): show = _current_show() tracks = db.get_show_tracks(show.id) return { @@ -81,7 +81,7 @@ def create_app( } @app.get("/playlist/{position}") - def playlist_track(position: int): + def playlist_track(position: int, _=Depends(_require_admin)): show = _current_show() track = db.get_show_track_by_position(show.id, position) if track is None: @@ -89,7 +89,7 @@ def create_app( return track @app.get("/shows") - def list_shows(limit: int = 20, offset: int = 0): + def list_shows(limit: int = 20, offset: int = 0, _=Depends(_require_admin)): shows = db.list_shows(limit=limit, offset=offset) return [ { @@ -103,7 +103,7 @@ def create_app( ] @app.get("/shows/by-episode/{episode_number}") - def show_by_episode(episode_number: int): + def show_by_episode(episode_number: int, _=Depends(_require_admin)): show = db.get_show_by_episode_number(episode_number) if show is None: raise HTTPException(status_code=404, detail=f"No show with episode number {episode_number}") @@ -117,7 +117,7 @@ def create_app( } @app.get("/shows/{show_id}") - def show_detail(show_id: int): + def show_detail(show_id: int, _=Depends(_require_admin)): shows = db.list_shows(limit=1000, offset=0) show = next((s for s in shows if s.id == show_id), None) if show is None: diff --git a/src/ntr_fetcher/static/dashboard.html b/src/ntr_fetcher/static/dashboard.html index 8c2661d..562fc0e 100644 --- a/src/ntr_fetcher/static/dashboard.html +++ b/src/ntr_fetcher/static/dashboard.html @@ -131,6 +131,10 @@ let showCache = {}; let activeShowId = null; + function authFetch(url) { + return fetch(url, { headers: { "Authorization": "Bearer " + WS_TOKEN } }); + } + function showToast(msg, isError) { const t = document.getElementById("toast"); t.textContent = msg; @@ -178,7 +182,7 @@ async function loadAllShows() { try { - const resp = await fetch("/shows?limit=200"); + const resp = await authFetch("/shows?limit=200"); if (!resp.ok) throw new Error("Failed to load shows"); allShows = await resp.json(); } catch (e) { @@ -186,7 +190,7 @@ } try { - const resp = await fetch("/playlist"); + const resp = await authFetch("/playlist"); if (!resp.ok) throw new Error("Failed to load current playlist"); const current = await resp.json(); showCache[current.show_id] = current; @@ -235,7 +239,7 @@ document.getElementById("show-content").innerHTML = "

Loading...

"; try { - const resp = await fetch(`/shows/${showId}`); + const resp = await authFetch(`/shows/${showId}`); if (!resp.ok) throw new Error("Failed to load show"); const data = await resp.json(); showCache[showId] = data; diff --git a/tests/test_api.py b/tests/test_api.py index 14db6e2..b7cbfca 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -57,9 +57,12 @@ def test_health(client): assert data["poller_alive"] is True +AUTH = {"Authorization": "Bearer test-token"} + + def test_playlist(client, db): _seed_show(db) - resp = client.get("/playlist") + resp = client.get("/playlist", headers=AUTH) assert resp.status_code == 200 data = resp.json() assert "episode_number" in data @@ -70,20 +73,20 @@ def test_playlist(client, db): def test_playlist_by_position(client, db): _seed_show(db) - resp = client.get("/playlist/2") + resp = client.get("/playlist/2", headers=AUTH) assert resp.status_code == 200 assert resp.json()["title"] == "Song B" def test_playlist_by_position_not_found(client, db): _seed_show(db) - resp = client.get("/playlist/99") + resp = client.get("/playlist/99", headers=AUTH) assert resp.status_code == 404 def test_shows_list(client, db): _seed_show(db) - resp = client.get("/shows") + resp = client.get("/shows", headers=AUTH) assert resp.status_code == 200 data = resp.json() assert len(data) >= 1 @@ -92,7 +95,7 @@ def test_shows_list(client, db): def test_shows_detail(client, db): show = _seed_show(db) - resp = client.get(f"/shows/{show.id}") + resp = client.get(f"/shows/{show.id}", headers=AUTH) assert resp.status_code == 200 data = resp.json() assert "episode_number" in data @@ -132,7 +135,7 @@ def test_show_by_episode(client, db): datetime(2026, 3, 14, 1, 0, 0, tzinfo=timezone.utc), "{}") db.upsert_track(t1) db.set_show_tracks(show.id, [t1.id]) - resp = client.get("/shows/by-episode/530") + resp = client.get("/shows/by-episode/530", headers=AUTH) assert resp.status_code == 200 data = resp.json() assert data["episode_number"] == 530 @@ -140,7 +143,7 @@ def test_show_by_episode(client, db): def test_show_by_episode_not_found(client): - resp = client.get("/shows/by-episode/999") + resp = client.get("/shows/by-episode/999", headers=AUTH) assert resp.status_code == 404 @@ -154,6 +157,46 @@ def test_no_login_route_without_config(client): assert resp.status_code == 404 +# --- Auth-required (401) tests --- + + +def test_playlist_requires_auth(client, db): + _seed_show(db) + resp = client.get("/playlist") + assert resp.status_code == 401 + + +def test_playlist_with_token(client, db): + _seed_show(db) + resp = client.get("/playlist", headers=AUTH) + assert resp.status_code == 200 + + +def test_playlist_by_position_requires_auth(client, db): + _seed_show(db) + resp = client.get("/playlist/1") + assert resp.status_code == 401 + + +def test_shows_requires_auth(client): + resp = client.get("/shows") + assert resp.status_code == 401 + + +def test_shows_detail_requires_auth(client, db): + show = _seed_show(db) + resp = client.get(f"/shows/{show.id}") + assert resp.status_code == 401 + + +def test_show_by_episode_requires_auth(client, db): + week_start = datetime(2026, 3, 12, 2, 0, 0, tzinfo=timezone.utc) + week_end = datetime(2026, 3, 19, 2, 0, 0, tzinfo=timezone.utc) + db.get_or_create_show(week_start, week_end, episode_number=530) + resp = client.get("/shows/by-episode/530") + assert resp.status_code == 401 + + # --- Public endpoint tests ---