feat: lock playlist/shows endpoints behind Bearer token auth

The five internal endpoints (/playlist, /playlist/{position}, /shows,
/shows/by-episode/{ep}, /shows/{id}) now require admin authentication.
Dashboard JS, Sopel plugin, and Limnoria plugin updated to send the
token on GET requests. Six new 401 tests added.

Made-with: Cursor
This commit is contained in:
cottongin
2026-04-02 12:06:27 -04:00
parent 425a7047c3
commit 4dd3f43ae3
5 changed files with 92 additions and 31 deletions

View File

@@ -28,9 +28,12 @@ class ApiError(Exception):
super().__init__(f"{status_code}: {detail}") 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}" 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: try:
with urllib.request.urlopen(req, timeout=10) as resp: with urllib.request.urlopen(req, timeout=10) as resp:
raw = resp.read().decode() raw = resp.read().decode()
@@ -234,8 +237,9 @@ class NtrPlaylist(callbacks.Plugin):
if match: if match:
position = match.group(1) position = match.group(1)
base_url = self.registryValue("apiBaseUrl") base_url = self.registryValue("apiBaseUrl")
token = self.registryValue("adminToken")
try: try:
data = _api_get(base_url, f"/playlist/{position}") data = _api_get(base_url, f"/playlist/{position}", token)
irc.reply(format_track(data)) irc.reply(format_track(data))
except ApiError as exc: except ApiError as exc:
LOGGER.warning("API error for !%s: %s", position, exc) LOGGER.warning("API error for !%s: %s", position, exc)
@@ -260,8 +264,9 @@ class NtrPlaylist(callbacks.Plugin):
irc.reply("Usage: !song <episode> <position>") irc.reply("Usage: !song <episode> <position>")
return return
base_url = self.registryValue("apiBaseUrl") base_url = self.registryValue("apiBaseUrl")
token = self.registryValue("adminToken")
try: 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: except ApiError as exc:
LOGGER.warning("API error for !song %s %s: %s", episode, position, exc) LOGGER.warning("API error for !song %s %s: %s", episode, position, exc)
irc.reply(exc.detail) irc.reply(exc.detail)
@@ -280,6 +285,7 @@ class NtrPlaylist(callbacks.Plugin):
Returns the playlist for the current show, or a specific episode. Returns the playlist for the current show, or a specific episode.
""" """
base_url = self.registryValue("apiBaseUrl") base_url = self.registryValue("apiBaseUrl")
token = self.registryValue("adminToken")
if text and text.strip(): if text and text.strip():
try: try:
episode = int(text.strip()) episode = int(text.strip())
@@ -287,14 +293,14 @@ class NtrPlaylist(callbacks.Plugin):
irc.reply("Usage: !playlist [episode]") irc.reply("Usage: !playlist [episode]")
return return
try: 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: except ApiError as exc:
LOGGER.warning("API error for playlist: %s", exc) LOGGER.warning("API error for playlist: %s", exc)
irc.reply(exc.detail) irc.reply(exc.detail)
return return
else: else:
try: try:
data = _api_get(base_url, "/playlist") data = _api_get(base_url, "/playlist", token)
except ApiError as exc: except ApiError as exc:
LOGGER.warning("API error for playlist: %s", exc) LOGGER.warning("API error for playlist: %s", exc)
irc.reply(exc.detail) irc.reply(exc.detail)
@@ -316,8 +322,9 @@ class NtrPlaylist(callbacks.Plugin):
irc.reply("Usage: !lastshow <position>") irc.reply("Usage: !lastshow <position>")
return return
base_url = self.registryValue("apiBaseUrl") base_url = self.registryValue("apiBaseUrl")
token = self.registryValue("adminToken")
try: try:
shows = _api_get(base_url, "/shows?limit=2") shows = _api_get(base_url, "/shows?limit=2", token)
except ApiError as exc: except ApiError as exc:
LOGGER.warning("API error for lastshow: %s", exc) LOGGER.warning("API error for lastshow: %s", exc)
irc.reply(exc.detail) irc.reply(exc.detail)
@@ -327,7 +334,7 @@ class NtrPlaylist(callbacks.Plugin):
return return
prev_show_id = shows[1]["id"] prev_show_id = shows[1]["id"]
try: 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: except ApiError as exc:
LOGGER.warning("API error for lastshow show %s: %s", prev_show_id, exc) LOGGER.warning("API error for lastshow show %s: %s", prev_show_id, exc)
irc.reply(exc.detail) irc.reply(exc.detail)

View File

@@ -143,9 +143,12 @@ class ApiError(Exception):
super().__init__(f"{status_code}: {detail}") 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}" 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: try:
with urllib.request.urlopen(req, timeout=10) as resp: with urllib.request.urlopen(req, timeout=10) as resp:
raw = resp.read().decode() raw = resp.read().decode()
@@ -235,9 +238,10 @@ def _is_admin(bot, nick: str) -> bool:
@plugin.rule(r"^!(\d+)$") @plugin.rule(r"^!(\d+)$")
def ntr_playlist_position(bot, trigger): def ntr_playlist_position(bot, trigger):
base_url = bot.settings.ntr_playlist.api_base_url base_url = bot.settings.ntr_playlist.api_base_url
token = bot.settings.ntr_playlist.admin_token
position = trigger.group(1) position = trigger.group(1)
try: try:
data = _api_get(base_url, f"/playlist/{position}") data = _api_get(base_url, f"/playlist/{position}", token)
bot.say(format_track(data)) bot.say(format_track(data))
except ApiError as e: except ApiError as e:
LOGGER.warning("API error for !%s: %s", position, e) LOGGER.warning("API error for !%s: %s", position, e)
@@ -261,8 +265,9 @@ def ntr_song(bot, trigger):
bot.say("Usage: !song <episode> <position>") bot.say("Usage: !song <episode> <position>")
return return
base_url = bot.settings.ntr_playlist.api_base_url base_url = bot.settings.ntr_playlist.api_base_url
token = bot.settings.ntr_playlist.admin_token
try: 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: except ApiError as e:
LOGGER.warning("API error for !song %s %s: %s", episode, position, e) LOGGER.warning("API error for !song %s %s: %s", episode, position, e)
bot.say(e.detail) bot.say(e.detail)
@@ -279,6 +284,7 @@ def ntr_song(bot, trigger):
def ntr_playlist(bot, trigger): def ntr_playlist(bot, trigger):
raw = trigger.group(2) raw = trigger.group(2)
base_url = bot.settings.ntr_playlist.api_base_url base_url = bot.settings.ntr_playlist.api_base_url
token = bot.settings.ntr_playlist.admin_token
if raw and raw.strip(): if raw and raw.strip():
try: try:
episode = int(raw.strip()) episode = int(raw.strip())
@@ -286,14 +292,14 @@ def ntr_playlist(bot, trigger):
bot.say("Usage: !playlist [episode]") bot.say("Usage: !playlist [episode]")
return return
try: 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: except ApiError as e:
LOGGER.warning("API error for !playlist: %s", e) LOGGER.warning("API error for !playlist: %s", e)
bot.say(e.detail) bot.say(e.detail)
return return
else: else:
try: try:
data = _api_get(base_url, "/playlist") data = _api_get(base_url, "/playlist", token)
except ApiError as e: except ApiError as e:
LOGGER.warning("API error for !playlist: %s", e) LOGGER.warning("API error for !playlist: %s", e)
bot.say(e.detail) bot.say(e.detail)
@@ -313,8 +319,9 @@ def ntr_lastshow(bot, trigger):
bot.say("Usage: !lastshow <position>") bot.say("Usage: !lastshow <position>")
return return
base_url = bot.settings.ntr_playlist.api_base_url base_url = bot.settings.ntr_playlist.api_base_url
token = bot.settings.ntr_playlist.admin_token
try: try:
shows = _api_get(base_url, "/shows?limit=2") shows = _api_get(base_url, "/shows?limit=2", token)
except ApiError as e: except ApiError as e:
LOGGER.warning("API error for !lastshow: %s", e) LOGGER.warning("API error for !lastshow: %s", e)
bot.say(e.detail) bot.say(e.detail)
@@ -324,7 +331,7 @@ def ntr_lastshow(bot, trigger):
return return
prev_show_id = shows[1]["id"] prev_show_id = shows[1]["id"]
try: 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: except ApiError as e:
LOGGER.warning("API error for !lastshow show %s: %s", prev_show_id, e) LOGGER.warning("API error for !lastshow show %s: %s", prev_show_id, e)
bot.say(e.detail) bot.say(e.detail)

View File

@@ -69,7 +69,7 @@ def create_app(
} }
@app.get("/playlist") @app.get("/playlist")
def playlist(): def playlist(_=Depends(_require_admin)):
show = _current_show() show = _current_show()
tracks = db.get_show_tracks(show.id) tracks = db.get_show_tracks(show.id)
return { return {
@@ -81,7 +81,7 @@ def create_app(
} }
@app.get("/playlist/{position}") @app.get("/playlist/{position}")
def playlist_track(position: int): def playlist_track(position: int, _=Depends(_require_admin)):
show = _current_show() show = _current_show()
track = db.get_show_track_by_position(show.id, position) track = db.get_show_track_by_position(show.id, position)
if track is None: if track is None:
@@ -89,7 +89,7 @@ def create_app(
return track return track
@app.get("/shows") @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) shows = db.list_shows(limit=limit, offset=offset)
return [ return [
{ {
@@ -103,7 +103,7 @@ def create_app(
] ]
@app.get("/shows/by-episode/{episode_number}") @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) show = db.get_show_by_episode_number(episode_number)
if show is None: if show is None:
raise HTTPException(status_code=404, detail=f"No show with episode number {episode_number}") 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}") @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) shows = db.list_shows(limit=1000, offset=0)
show = next((s for s in shows if s.id == show_id), None) show = next((s for s in shows if s.id == show_id), None)
if show is None: if show is None:

View File

@@ -131,6 +131,10 @@
let showCache = {}; let showCache = {};
let activeShowId = null; let activeShowId = null;
function authFetch(url) {
return fetch(url, { headers: { "Authorization": "Bearer " + WS_TOKEN } });
}
function showToast(msg, isError) { function showToast(msg, isError) {
const t = document.getElementById("toast"); const t = document.getElementById("toast");
t.textContent = msg; t.textContent = msg;
@@ -178,7 +182,7 @@
async function loadAllShows() { async function loadAllShows() {
try { 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"); if (!resp.ok) throw new Error("Failed to load shows");
allShows = await resp.json(); allShows = await resp.json();
} catch (e) { } catch (e) {
@@ -186,7 +190,7 @@
} }
try { try {
const resp = await fetch("/playlist"); const resp = await authFetch("/playlist");
if (!resp.ok) throw new Error("Failed to load current playlist"); if (!resp.ok) throw new Error("Failed to load current playlist");
const current = await resp.json(); const current = await resp.json();
showCache[current.show_id] = current; showCache[current.show_id] = current;
@@ -235,7 +239,7 @@
document.getElementById("show-content").innerHTML = "<p>Loading...</p>"; document.getElementById("show-content").innerHTML = "<p>Loading...</p>";
try { try {
const resp = await fetch(`/shows/${showId}`); const resp = await authFetch(`/shows/${showId}`);
if (!resp.ok) throw new Error("Failed to load show"); if (!resp.ok) throw new Error("Failed to load show");
const data = await resp.json(); const data = await resp.json();
showCache[showId] = data; showCache[showId] = data;

View File

@@ -57,9 +57,12 @@ def test_health(client):
assert data["poller_alive"] is True assert data["poller_alive"] is True
AUTH = {"Authorization": "Bearer test-token"}
def test_playlist(client, db): def test_playlist(client, db):
_seed_show(db) _seed_show(db)
resp = client.get("/playlist") resp = client.get("/playlist", headers=AUTH)
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert "episode_number" in data assert "episode_number" in data
@@ -70,20 +73,20 @@ def test_playlist(client, db):
def test_playlist_by_position(client, db): def test_playlist_by_position(client, db):
_seed_show(db) _seed_show(db)
resp = client.get("/playlist/2") resp = client.get("/playlist/2", headers=AUTH)
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["title"] == "Song B" assert resp.json()["title"] == "Song B"
def test_playlist_by_position_not_found(client, db): def test_playlist_by_position_not_found(client, db):
_seed_show(db) _seed_show(db)
resp = client.get("/playlist/99") resp = client.get("/playlist/99", headers=AUTH)
assert resp.status_code == 404 assert resp.status_code == 404
def test_shows_list(client, db): def test_shows_list(client, db):
_seed_show(db) _seed_show(db)
resp = client.get("/shows") resp = client.get("/shows", headers=AUTH)
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert len(data) >= 1 assert len(data) >= 1
@@ -92,7 +95,7 @@ def test_shows_list(client, db):
def test_shows_detail(client, db): def test_shows_detail(client, db):
show = _seed_show(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 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert "episode_number" in data 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), "{}") datetime(2026, 3, 14, 1, 0, 0, tzinfo=timezone.utc), "{}")
db.upsert_track(t1) db.upsert_track(t1)
db.set_show_tracks(show.id, [t1.id]) 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 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["episode_number"] == 530 assert data["episode_number"] == 530
@@ -140,7 +143,7 @@ def test_show_by_episode(client, db):
def test_show_by_episode_not_found(client): 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 assert resp.status_code == 404
@@ -154,6 +157,46 @@ def test_no_login_route_without_config(client):
assert resp.status_code == 404 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 --- # --- Public endpoint tests ---