Add web dashboard auth system and session management actions

Implement per-admin authentication with IRC-managed accounts
(addSongAdmin/removeSongAdmin/listSongAdmins), session-based login,
and admin presence tracking via WebSocket. Legacy webAuthToken
retained as fallback.

Add rename, clear, and delete actions for archived sessions with
themed modal confirmations (admin-only UI).

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-28 12:02:42 -04:00
parent 7340d59b8e
commit ea40251e0e
7 changed files with 1221 additions and 75 deletions

View File

@@ -0,0 +1,258 @@
---
name: Apple Music Integration
overview: Exploratory investigation of Apple Music API integration options for adding requests to a user's Apple Music queue/playlist and automatically detecting when songs are played, with assessment of feasibility, prerequisites, and architectural approaches.
todos:
- id: prereqs
content: Set up Apple Developer Program account, create MusicKit identifier, generate private key
status: pending
- id: apple-music-module
content: Create SongRequest/apple_music.py with JWT generation and REST API wrapper
status: pending
- id: musickit-js-embed
content: Embed MusicKit JS v3 in the dashboard, add authorize flow and queue control
status: pending
- id: now-playing-detection
content: Add playbackStateDidChange listener in dashboard JS + /api/now-playing endpoint in web.py
status: pending
- id: auto-played-matching
content: Implement trackId matching logic in store/plugin to auto-mark requests as played
status: pending
- id: config-values
content: Add config entries for Apple Developer key, team ID, key ID, playlist ID, and feature toggles
status: pending
- id: recently-played-poller
content: (Optional) Add server-side polling of recently-played API as a fallback
status: pending
isProject: false
---
# Apple Music Integration: Feasibility and Implementation Plan
## Prerequisites (Required for Both Features)
Before any integration work, you need:
- **Paid Apple Developer Program membership** ($99/year) -- required to generate MusicKit keys
- **MusicKit identifier + private key** from Certificates, Identifiers & Profiles in the Apple Developer portal
- **Developer Token (JWT)** -- an ES256-signed JWT with your Team ID and key ID, valid up to 6 months
- **Music User Token** -- obtained via MusicKit JS authorization flow in the browser; required for any personal library/playlist operations. The dashboard operator must have an **active Apple Music subscription**
The current plugin uses the **public iTunes Search API** (no auth needed). Both proposed features require authenticated access to a specific user's Apple Music account.
---
## Feature 1: Add Requests to Apple Music Playlist/Queue
### How It Would Work
There are two viable approaches, each with different trade-offs:
### Approach A: Server-Side REST API (Add to Playlist)
The Apple Music REST API supports adding tracks to a user's library playlist:
```
POST https://api.music.apple.com/v1/me/library/playlists/{playlist_id}/tracks
Authorization: Bearer {developer_token}
Music-User-Token: {user_token}
{
"data": [
{ "id": "{catalog_song_id}", "type": "songs" }
]
}
```
**Architecture:**
```mermaid
sequenceDiagram
participant IRC as IRC User
participant Bot as Limnoria Bot
participant iTunes as iTunes Search API
participant Dashboard as Web Dashboard
participant AM as Apple Music API
IRC->>Bot: !request Artist - Title
Bot->>iTunes: Search query
iTunes-->>Bot: Track results (includes trackId)
Bot->>Dashboard: WebSocket push (new card)
Dashboard->>AM: POST /v1/me/.../tracks (on approve)
AM-->>Dashboard: 200 OK (added to playlist)
Dashboard->>Bot: WebSocket notify (added)
```
**Implementation steps:**
- Add a new module `[SongRequest/apple_music.py](SongRequest/apple_music.py)` for JWT generation (using `PyJWT` + `cryptography`) and REST API calls
- Add config values for Apple Developer key path, key ID, team ID, target playlist ID, and stored Music User Token
- The web dashboard handles the MusicKit JS authorization flow once (popup login), stores the Music User Token, and sends it to the bot backend
- When a request is approved (or auto-approved), the bot calls `POST /v1/me/library/playlists/{id}/tracks` with the iTunes `trackId` (which maps to Apple Music catalog IDs)
- New config option: `appleMusicAutoAdd` (boolean) to toggle this behavior
**Key concern:** The `trackId` from the iTunes Search API should map to Apple Music catalog song IDs, but this needs verification. The iTunes Search API returns `trackId` (numeric), while the Apple Music API expects string catalog IDs. These are typically the same value, just as a string.
**Pros:** Fully server-side, works without the dashboard open, songs appear in a persistent playlist
**Cons:** Adds to a playlist only (not the live playback queue), tracks go to the end of the playlist, no position control, Music User Token expires and needs periodic re-auth
### Approach B: MusicKit JS Web Player (Add to Queue + Play)
Embed MusicKit JS in the web dashboard to directly control Apple Music playback:
```javascript
const music = MusicKit.getInstance();
await music.authorize();
await music.setQueue({ songs: [catalogSongId] });
// Or append to existing queue
await music.playNext({ songs: [catalogSongId] });
```
**Architecture:**
```mermaid
sequenceDiagram
participant IRC as IRC User
participant Bot as Limnoria Bot
participant Dashboard as Web Dashboard + MusicKit JS
participant AM as Apple Music (Playback)
IRC->>Bot: !request Artist - Title
Bot->>Dashboard: WebSocket push (new request)
Note over Dashboard: Operator approves
Dashboard->>AM: MusicKit.playNext(songId)
AM-->>Dashboard: Song added to queue
Note over Dashboard: Apple Music plays on this device
```
**Implementation steps:**
- Add MusicKit JS SDK to `[SongRequest/templates/index.html](SongRequest/templates/index.html)` via `<script src="https://js-cdn.music.apple.com/musickit/v3/musickit.js">`
- Add a "Connect Apple Music" button in the dashboard that calls `music.authorize()`
- On approve/auto-approve, call `music.playNext({ songs: [trackId] })` to queue the song
- Optionally, add a mini player widget to the dashboard showing the current queue
**Pros:** Actually controls the live playback queue, songs play in order, real-time control from the dashboard
**Cons:** Requires the dashboard to be open in a browser on the machine doing playback, browser tab must stay active, only controls playback on that specific device/session
### Recommendation
**Approach B (MusicKit JS)** is better suited to the DJ/streamer use case -- it puts songs directly into the playback queue rather than just appending to a playlist. **Approach A** is useful as a secondary feature (e.g., building a "requested songs" playlist for later). Both can coexist.
---
## Feature 2: Track When Requests Get Played Automatically
### The Problem
Apple Music has **no server-side webhook or push notification** for "now playing" events. There is no way for the bot to passively learn that a song started playing without active client-side monitoring.
### Viable Approaches
### Approach A: MusicKit JS Event Monitoring (Best Fit)
If MusicKit JS is already embedded (from Feature 1, Approach B), the dashboard can listen for playback events:
```javascript
const music = MusicKit.getInstance();
music.addEventListener('playbackStateDidChange', (event) => {
if (event.state === MusicKit.PlaybackStates.playing) {
const item = music.nowPlayingItem;
// Send track ID to bot backend via WebSocket/fetch
fetch('/api/now-playing', {
method: 'POST',
body: JSON.stringify({ trackId: item.id, title: item.title })
});
}
});
```
**Architecture:**
```mermaid
sequenceDiagram
participant AM as Apple Music (Playback)
participant Dashboard as Web Dashboard + MusicKit JS
participant Bot as Limnoria Bot
participant IRC as IRC Channel
AM->>Dashboard: playbackStateDidChange (playing)
Dashboard->>Dashboard: Check nowPlayingItem.id
Dashboard->>Bot: POST /api/now-playing {trackId}
Bot->>Bot: Match trackId against approved requests
Bot->>Bot: Update status to "played"
Bot->>IRC: [NOW PLAYING] Song - Artist (requested by nick)
Bot->>Dashboard: WebSocket push (card updated)
```
**Implementation steps:**
- Add `playbackStateDidChange` and `nowPlayingItemDidChange` event listeners in the dashboard JS
- When a new song starts playing, the dashboard POSTs the track info to a new `/api/now-playing` endpoint
- The bot matches the `trackId` against approved requests in the database
- If matched, auto-updates the request status to "played" and announces in IRC
- Add a `played_at` timestamp column to the requests table for precise tracking
**Pros:** Automatic, real-time, no manual "Mark Played" clicking needed
**Cons:** Requires the dashboard + MusicKit JS to be running, only detects playback from the MusicKit JS session (not from the Apple Music app directly)
### Approach B: Recently Played Polling (Server-Side Fallback)
Poll the Apple Music REST API's recently-played endpoint:
```
GET https://api.music.apple.com/v1/me/recent/played/tracks
Authorization: Bearer {developer_token}
Music-User-Token: {user_token}
```
**Implementation steps:**
- Add a background polling loop (every 30-60 seconds) that fetches recently played tracks
- Compare against approved requests by `trackId`
- Auto-mark matched requests as "played"
**Pros:** Works without the dashboard being open, detects plays from any device/app
**Cons:** Not real-time (30-60s delay), the recently-played API has undocumented limitations (unclear update frequency, unclear history depth, may miss short plays), Music User Token must be stored server-side and refreshed
### Approach C: Hybrid
Use MusicKit JS events (Approach A) when the dashboard is open, and fall back to recently-played polling (Approach B) when it's not. The bot prefers the real-time signal but has the poller as a safety net.
### Recommendation
**Approach A (MusicKit JS events)** is the most reliable and real-time option, especially since the dashboard is already the operator's control panel. **Approach B** could supplement it but has reliability concerns due to the poorly-documented recently-played API.
---
## Summary of Feasibility
| Aspect | Feasibility | Notes |
| -------------------------------- | ------------ | ------------------------------------------ |
| Add to playlist (REST) | High | Well-documented API, straightforward |
| Add to queue (MusicKit JS) | High | Requires dashboard open during playback |
| Auto-detect played (MusicKit JS) | High | Real-time, but scoped to dashboard session |
| Auto-detect played (server-side) | Medium | Recently-played API is poorly documented |
| Server-side webhook/push | Not possible | Apple provides no such mechanism |
## Key Files to Modify
- `[SongRequest/templates/index.html](SongRequest/templates/index.html)` -- embed MusicKit JS, add authorize flow, add event listeners
- `[SongRequest/web.py](SongRequest/web.py)` -- new `/api/now-playing` endpoint, MusicKit config endpoint
- `[SongRequest/plugin.py](SongRequest/plugin.py)` -- auto-play detection callback, IRC announcements
- `[SongRequest/config.py](SongRequest/config.py)` -- new config values for Apple Developer credentials
- New: `[SongRequest/apple_music.py](SongRequest/apple_music.py)` -- JWT generation, REST API wrapper (for Approach A playlist add and/or recently-played polling)
- `[SongRequest/store.py](SongRequest/store.py)` -- optional `played_at` column, query for matching trackId against approved requests
## New Dependencies
- `PyJWT` + `cryptography` -- for generating Apple Developer JWT tokens (server-side)
- No new JS dependencies -- MusicKit JS is loaded from Apple's CDN

View File

@@ -59,8 +59,9 @@ conf.registerGlobalValue(
"webAuthToken",
registry.String(
"",
_("""Secret token required for web dashboard admin actions.
Set this to a random string."""),
_("""(Deprecated) Legacy shared token for web dashboard auth.
Prefer per-admin accounts via addSongAdmin. If set, this
token is still accepted as a fallback for API access."""),
private=True,
),
)

View File

@@ -493,5 +493,46 @@ class SongRequest(callbacks.Plugin):
stopsession = wrap(stopsession, ["admin"])
# ------------------------------------------------------------------
# Web admin management
# ------------------------------------------------------------------
def addsongadmin(self, irc, msg, args, username, key):
"""<username> <adminkey>
Add a web dashboard admin account. Requires bot admin.
"""
if self.store.add_admin(username, key):
irc.reply(f'Web admin "{username}" added.', prefixNick=True, private=True)
else:
irc.reply(f'Username "{username}" already exists.', prefixNick=True, private=True)
addsongadmin = wrap(addsongadmin, ["admin", "somethingWithoutSpaces", "text"])
def removesongadmin(self, irc, msg, args, username):
"""<username>
Remove a web dashboard admin account. Requires bot admin.
"""
if self.store.remove_admin(username):
irc.reply(f'Web admin "{username}" removed.', prefixNick=True, private=True)
else:
irc.reply(f'No admin named "{username}".', prefixNick=True, private=True)
removesongadmin = wrap(removesongadmin, ["admin", "somethingWithoutSpaces"])
def listsongadmins(self, irc, msg, args):
"""(takes no arguments)
List all web dashboard admin accounts. Requires bot admin.
"""
admins = self.store.list_admins()
if admins:
irc.reply("Web admins: " + ", ".join(admins), prefixNick=True, private=True)
else:
irc.reply("No web admins configured.", prefixNick=True, private=True)
listsongadmins = wrap(listsongadmins, ["admin"])
Class = SongRequest

View File

@@ -1,4 +1,6 @@
import hashlib
import os
import secrets
import sqlite3
import time
import threading
@@ -35,6 +37,8 @@ CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status);
CREATE INDEX IF NOT EXISTS idx_requests_channel ON requests(channel);
"""
SESSION_EXPIRY_SECONDS = 86400 # 24 hours
MIGRATIONS = [
"ALTER TABLE requests ADD COLUMN alternates_json TEXT NOT NULL DEFAULT ''",
"ALTER TABLE requests ADD COLUMN session_id INTEGER DEFAULT NULL",
@@ -45,6 +49,22 @@ MIGRATIONS = [
ended_at REAL
)""",
"CREATE INDEX IF NOT EXISTS idx_requests_session ON requests(session_id)",
"""CREATE TABLE IF NOT EXISTS web_admins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
key_hash TEXT NOT NULL,
salt TEXT NOT NULL,
created_at REAL NOT NULL
)""",
"""CREATE TABLE IF NOT EXISTS web_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE,
admin_id INTEGER NOT NULL REFERENCES web_admins(id),
username TEXT NOT NULL,
created_at REAL NOT NULL,
expires_at REAL NOT NULL
)""",
"CREATE INDEX IF NOT EXISTS idx_web_sessions_token ON web_sessions(token)",
]
@@ -104,6 +124,100 @@ class RequestStore:
conn.execute("PRAGMA journal_mode=WAL")
return conn
# ------------------------------------------------------------------
# Web Admins
# ------------------------------------------------------------------
@staticmethod
def _hash_key(key: str, salt: str) -> str:
return hashlib.sha256((salt + key).encode("utf-8")).hexdigest()
def add_admin(self, username: str, key: str) -> bool:
salt = secrets.token_hex(16)
key_hash = self._hash_key(key, salt)
now = time.time()
try:
with self._lock, self._connect() as conn:
conn.execute(
"INSERT INTO web_admins (username, key_hash, salt, created_at) VALUES (?, ?, ?, ?)",
(username, key_hash, salt, now),
)
return True
except sqlite3.IntegrityError:
return False
def remove_admin(self, username: str) -> bool:
with self._lock, self._connect() as conn:
row = conn.execute(
"SELECT id FROM web_admins WHERE username = ?", (username,)
).fetchone()
if not row:
return False
admin_id = row["id"]
conn.execute("DELETE FROM web_sessions WHERE admin_id = ?", (admin_id,))
conn.execute("DELETE FROM web_admins WHERE id = ?", (admin_id,))
return True
def list_admins(self) -> List[str]:
with self._lock, self._connect() as conn:
rows = conn.execute(
"SELECT username FROM web_admins ORDER BY username"
).fetchall()
return [r["username"] for r in rows]
def validate_admin(self, username: str, key: str) -> Optional[int]:
with self._lock, self._connect() as conn:
row = conn.execute(
"SELECT id, key_hash, salt FROM web_admins WHERE username = ?",
(username,),
).fetchone()
if not row:
return None
expected = self._hash_key(key, row["salt"])
if not secrets.compare_digest(expected, row["key_hash"]):
return None
return row["id"]
# ------------------------------------------------------------------
# Web Sessions
# ------------------------------------------------------------------
def create_session_token(self, admin_id: int, username: str) -> str:
token = secrets.token_urlsafe(32)
now = time.time()
expires = now + SESSION_EXPIRY_SECONDS
with self._lock, self._connect() as conn:
conn.execute(
"INSERT INTO web_sessions (token, admin_id, username, created_at, expires_at) VALUES (?, ?, ?, ?, ?)",
(token, admin_id, username, now, expires),
)
return token
def validate_session(self, token: str) -> Optional[str]:
if not token:
return None
now = time.time()
with self._lock, self._connect() as conn:
row = conn.execute(
"SELECT username, expires_at FROM web_sessions WHERE token = ?",
(token,),
).fetchone()
if not row or row["expires_at"] < now:
return None
return row["username"]
def delete_session(self, token: str) -> None:
with self._lock, self._connect() as conn:
conn.execute("DELETE FROM web_sessions WHERE token = ?", (token,))
def cleanup_expired_sessions(self) -> int:
now = time.time()
with self._lock, self._connect() as conn:
cur = conn.execute(
"DELETE FROM web_sessions WHERE expires_at < ?", (now,)
)
return cur.rowcount
# ------------------------------------------------------------------
# Sessions
# ------------------------------------------------------------------
@@ -171,6 +285,29 @@ class RequestStore:
)
return cur.rowcount
def rename_session(self, session_id: int, name: str) -> Optional[Session]:
with self._lock, self._connect() as conn:
conn.execute(
"UPDATE sessions SET name = ? WHERE id = ?",
(name, session_id),
)
row = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
return Session(**dict(row)) if row else None
def clear_session_requests(self, session_id: int) -> int:
with self._lock, self._connect() as conn:
cur = conn.execute(
"DELETE FROM requests WHERE session_id = ?",
(session_id,),
)
return cur.rowcount
def delete_session(self, session_id: int) -> bool:
with self._lock, self._connect() as conn:
conn.execute("DELETE FROM requests WHERE session_id = ?", (session_id,))
cur = conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
return cur.rowcount > 0
# ------------------------------------------------------------------
# Requests
# ------------------------------------------------------------------

View File

@@ -76,6 +76,11 @@
min-height: 100vh;
}
.admin-only { display: none !important; }
body.is-admin .admin-only { display: flex !important; }
body.is-admin .admin-only-inline { display: inline-block !important; }
.admin-only-inline { display: none !important; }
.container {
max-width: 900px;
margin: 0 auto;
@@ -117,6 +122,31 @@
gap: 0.75rem;
}
.auth-controls {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
.auth-user {
color: var(--text-muted);
white-space: nowrap;
}
.auth-link {
color: var(--text-muted);
text-decoration: none;
font-size: 0.8125rem;
padding: 0.25rem 0.5rem;
border: 1px solid var(--border);
border-radius: 6px;
transition: all 0.15s;
white-space: nowrap;
}
.auth-link:hover { border-color: #444; color: var(--text); }
.toggle-label {
display: flex;
align-items: center;
@@ -579,6 +609,37 @@
padding: 0 1rem 1rem;
}
.archive-actions {
display: none;
gap: 0.25rem;
margin-left: 0.5rem;
flex-shrink: 0;
}
body.is-admin .archive-actions { display: flex; }
.archive-actions .btn-icon {
background: none;
border: none;
cursor: pointer;
padding: 0.2rem 0.35rem;
border-radius: 4px;
font-size: 0.8125rem;
color: var(--text-muted);
line-height: 1;
transition: background 0.15s, color 0.15s;
}
.archive-actions .btn-icon:hover {
background: var(--surface-hover);
color: var(--text);
}
.archive-actions .btn-icon.btn-danger:hover {
background: var(--reject-bg);
color: var(--reject);
}
/* ---- Toast notifications ---- */
#toast-container {
position: fixed;
@@ -705,6 +766,61 @@
margin-top: 1.25rem;
}
/* ---- Admin presence ---- */
#admin-presence {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 800;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.5rem 0.75rem;
display: none;
align-items: center;
gap: 0.5rem;
box-shadow: 0 2px 12px rgba(0,0,0,0.25);
font-size: 0.8125rem;
max-width: 320px;
}
#admin-presence.visible { display: flex; }
.presence-label {
color: var(--text-muted);
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.presence-pills {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
overflow: hidden;
}
.presence-pill {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.15rem 0.5rem;
background: var(--surface-hover);
border-radius: 12px;
color: var(--text);
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
}
.presence-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--approve);
flex-shrink: 0;
}
@media (max-width: 640px) {
.container { padding: 1rem; }
@@ -714,6 +830,8 @@
.toggle-text { display: none; }
.auth-user { display: none; }
.album-art { width: 72px; height: 72px; }
.request-card { gap: 0.75rem; padding: 0.75rem; }
@@ -739,6 +857,22 @@
.session-bar .btn { margin-left: 0; }
#toast-container { right: 0.5rem; left: 0.5rem; max-width: none; }
#admin-presence {
bottom: 0;
right: 0;
left: 0;
border-radius: 10px 10px 0 0;
max-width: none;
padding: 0.5rem 0.75rem;
}
.presence-pills {
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: none;
}
.presence-pills::-webkit-scrollbar { display: none; }
}
</style>
</head>
@@ -748,11 +882,14 @@
<div class="logo">&#9835;</div>
<h1>Song Requests</h1>
<div class="header-controls">
<label class="toggle-label" title="Open/close requests">
<label class="toggle-label admin-only" title="Open/close requests">
<span class="toggle-text" id="toggle-text">Open</span>
<input type="checkbox" id="requests-toggle" checked onchange="toggleRequests(this.checked)" />
<span class="toggle-slider"></span>
</label>
<div class="auth-controls" id="auth-controls">
<a class="auth-link" href="/login" id="auth-login-link">Admin Login</a>
</div>
<button class="theme-btn" id="theme-btn" onclick="cycleTheme()" title="Toggle theme">&#9790;</button>
<div id="status-dot" class="status-dot disconnected" title="Disconnected"></div>
</div>
@@ -762,7 +899,7 @@
<div id="session-bar" class="session-bar" style="display:none;">
<span class="session-name" id="session-name"></span>
<span class="session-duration" id="session-duration"></span>
<button class="btn btn-secondary" id="session-btn" onclick="handleSessionAction()">Start Session</button>
<button class="btn btn-secondary admin-only" id="session-btn" onclick="handleSessionAction()">Start Session</button>
</div>
<div id="channel-bar" class="channel-bar">
@@ -792,7 +929,7 @@
<div class="section">
<div class="section-header">
<div class="section-title" id="history-title">History</div>
<div class="section-actions">
<div class="section-actions admin-only">
<a id="export-btn" class="btn btn-secondary" href="#" onclick="exportMarkdown(event)">Export .md</a>
<button class="btn btn-secondary btn-danger-text" onclick="clearHistory()">Clear History</button>
</div>
@@ -806,6 +943,11 @@
<div id="toast-container"></div>
<div id="admin-presence">
<span class="presence-label">Online:</span>
<div class="presence-pills" id="presence-pills"></div>
</div>
<div class="modal-overlay" id="modal-overlay">
<div class="modal-box" id="modal-box">
<div class="modal-title" id="modal-title"></div>
@@ -816,7 +958,8 @@
</div>
<script>
var AUTH_TOKEN = "{{AUTH_TOKEN}}";
var isAdmin = false;
var adminUsername = '';
var ws = null;
var reconnectDelay = 1000;
var maxReconnectDelay = 30000;
@@ -826,6 +969,83 @@
var sessionTimerInterval = null;
var currentTab = 'queue';
function getAuthToken() {
return localStorage.getItem('authToken') || '';
}
function authHeaders() {
var token = getAuthToken();
var h = {'Content-Type': 'application/json'};
if (token) h['X-Auth-Token'] = token;
return h;
}
function tokenParam(url) {
var token = getAuthToken();
if (!token) return url;
return url + (url.indexOf('?') >= 0 ? '&' : '?') + 'token=' + encodeURIComponent(token);
}
// ---- Auth state ----
function applyAdminState(authenticated, username) {
isAdmin = authenticated;
adminUsername = username || '';
if (authenticated) {
document.body.classList.add('is-admin');
} else {
document.body.classList.remove('is-admin');
}
renderAuthControls();
}
function renderAuthControls() {
var el = document.getElementById('auth-controls');
if (isAdmin) {
el.innerHTML = '<span class="auth-user">' + escapeHtml(adminUsername) + '</span>'
+ '<a class="auth-link" href="#" onclick="doLogout(event)">Logout</a>';
} else {
el.innerHTML = '<a class="auth-link" href="/login">Admin Login</a>';
}
}
function doLogout(e) {
if (e) e.preventDefault();
var token = getAuthToken();
if (token) {
fetch('/api/auth/logout', {
method: 'POST',
headers: {'X-Auth-Token': token}
}).catch(function() {});
}
localStorage.removeItem('authToken');
localStorage.removeItem('adminUsername');
applyAdminState(false, '');
if (ws) { ws.close(); }
setTimeout(connectWS, 100);
}
function initAuth() {
var token = getAuthToken();
if (!token) {
applyAdminState(false, '');
return Promise.resolve();
}
return fetch('/api/auth/me', { headers: {'X-Auth-Token': token} })
.then(function(r) {
if (r.ok) return r.json();
throw new Error('invalid');
})
.then(function(data) {
localStorage.setItem('adminUsername', data.username);
applyAdminState(true, data.username);
})
.catch(function() {
localStorage.removeItem('authToken');
localStorage.removeItem('adminUsername');
applyAdminState(false, '');
});
}
// ---- Theme ----
var themeOrder = ['system', 'light', 'dark'];
var themeIcons = { system: '\u263E', light: '\u2600', dark: '\u263E' };
@@ -855,7 +1075,7 @@
var hash = location.hash.replace(/^#/, '');
if (hash) return hash.startsWith('#') ? hash : (hash.startsWith('%23') ? decodeURIComponent(hash) : '#' + hash);
var path = location.pathname.replace(/^\//, '');
if (path && path !== '' && !path.startsWith('api/') && !path.startsWith('static/') && !path.startsWith('ws')) {
if (path && path !== '' && !path.startsWith('api/') && !path.startsWith('static/') && !path.startsWith('ws') && path !== 'login') {
return path.startsWith('#') ? path : '#' + path;
}
return null;
@@ -930,7 +1150,7 @@
}
function fetchInto(elementId, url) {
if (AUTH_TOKEN) url += (url.indexOf('?') >= 0 ? '&' : '?') + 'token=' + encodeURIComponent(AUTH_TOKEN);
url = tokenParam(url);
fetch(url).then(function(r) { return r.text(); }).then(function(html) {
var el = document.getElementById(elementId);
if (el) { el.innerHTML = html; htmx.process(el); }
@@ -938,8 +1158,7 @@
}
function fetchChannels() {
var url = '/api/channels';
if (AUTH_TOKEN) url += '?token=' + encodeURIComponent(AUTH_TOKEN);
var url = tokenParam('/api/channels');
fetch(url).then(function(r) { return r.json(); }).then(function(channels) {
var bar = document.getElementById('channel-bar');
var pills = '<button class="channel-pill' + (activeChannel === null ? ' active' : '') + '" onclick="setChannel(null, this)">All</button>';
@@ -953,8 +1172,9 @@
// ---- Auth header for HTMX ----
document.body.addEventListener('htmx:configRequest', function(e) {
if (AUTH_TOKEN) {
e.detail.headers['X-Auth-Token'] = AUTH_TOKEN;
var token = getAuthToken();
if (token) {
e.detail.headers['X-Auth-Token'] = token;
}
});
@@ -971,20 +1191,18 @@
function toggleRequests(isOpen) {
fetch('/api/status', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'},
AUTH_TOKEN ? {'X-Auth-Token': AUTH_TOKEN} : {}),
headers: authHeaders(),
body: JSON.stringify({ open: isOpen })
});
applyRequestsOpen(isOpen);
}
(function fetchInitialStatus() {
var url = '/api/status';
if (AUTH_TOKEN) url += '?token=' + encodeURIComponent(AUTH_TOKEN);
fetch(url).then(function(r) { return r.json(); }).then(function(data) {
applyRequestsOpen(data.open);
}).catch(function() {});
})();
function fetchInitialStatus() {
fetch(tokenParam('/api/status'))
.then(function(r) { return r.json(); })
.then(function(data) { applyRequestsOpen(data.open); })
.catch(function() {});
}
// ---- Toast notifications ----
function showToast(message, duration) {
@@ -1103,6 +1321,22 @@
}
});
// ---- Admin presence ----
function updatePresence(admins) {
var container = document.getElementById('admin-presence');
var pillsEl = document.getElementById('presence-pills');
if (!admins || admins.length === 0) {
container.classList.remove('visible');
return;
}
var html = '';
admins.forEach(function(name) {
html += '<span class="presence-pill"><span class="presence-dot"></span>' + escapeHtml(name) + '</span>';
});
pillsEl.innerHTML = html;
container.classList.add('visible');
}
// ---- Sessions ----
function formatDuration(startTs) {
var elapsed = Math.floor(Date.now() / 1000 - startTs);
@@ -1127,7 +1361,7 @@
nameEl.textContent = session.name;
durEl.textContent = formatDuration(session.started_at);
btn.textContent = 'Stop Session';
btn.className = 'btn btn-reject';
btn.className = 'btn btn-reject admin-only';
btn.style.fontSize = '0.75rem';
btn.style.padding = '0.3rem 0.65rem';
titleEl.textContent = 'Session: ' + session.name;
@@ -1139,7 +1373,7 @@
nameEl.textContent = 'No active session';
durEl.textContent = '';
btn.textContent = 'Start Session';
btn.className = 'btn btn-secondary';
btn.className = 'btn btn-secondary admin-only';
btn.style.fontSize = '0.75rem';
btn.style.padding = '0.3rem 0.65rem';
titleEl.textContent = 'History';
@@ -1160,7 +1394,7 @@
onClick: function() {
fetch('/api/sessions/stop', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, AUTH_TOKEN ? {'X-Auth-Token': AUTH_TOKEN} : {}),
headers: authHeaders(),
body: JSON.stringify({ clear_remaining: false })
}).then(function() { fetchSessions(); reloadLists(); reloadArchivedSessions(); });
}
@@ -1168,7 +1402,7 @@
onConfirm: function() {
fetch('/api/sessions/stop', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, AUTH_TOKEN ? {'X-Auth-Token': AUTH_TOKEN} : {}),
headers: authHeaders(),
body: JSON.stringify({ clear_remaining: true })
}).then(function() { fetchSessions(); reloadLists(); reloadArchivedSessions(); });
}
@@ -1184,7 +1418,7 @@
onConfirm: function(name) {
fetch('/api/sessions/start', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, AUTH_TOKEN ? {'X-Auth-Token': AUTH_TOKEN} : {}),
headers: authHeaders(),
body: JSON.stringify({ name: name || '' })
}).then(function() { fetchSessions(); reloadLists(); });
}
@@ -1193,12 +1427,12 @@
}
function fetchSessions() {
var url = '/api/sessions';
if (AUTH_TOKEN) url += '?token=' + encodeURIComponent(AUTH_TOKEN);
fetch(url).then(function(r) { return r.json(); }).then(function(data) {
applySessionState(data.active);
renderArchivedSessions(data.archived);
}).catch(function() {});
fetch(tokenParam('/api/sessions'))
.then(function(r) { return r.json(); })
.then(function(data) {
applySessionState(data.active);
renderArchivedSessions(data.archived);
}).catch(function() {});
}
function reloadArchivedSessions() {
@@ -1221,6 +1455,11 @@
html += '<span>' + escapeHtml(s.name) + '</span>';
html += '<span class="archive-meta">' + start + (end ? ' \u2014 ' + end : '') + '</span>';
html += '<span class="archive-count">' + countLabel + '</span>';
html += '<span class="archive-actions">'
+ '<button class="btn-icon" title="Rename" onclick="event.stopPropagation(); renameSession(' + s.id + ', this)">&#9998;</button>'
+ '<button class="btn-icon" title="Clear requests" onclick="event.stopPropagation(); clearSession(' + s.id + ', this)">&#128465;</button>'
+ '<button class="btn-icon btn-danger" title="Delete session" onclick="event.stopPropagation(); deleteSession(' + s.id + ', this)">&#10005;</button>'
+ '</span>';
html += '</summary>';
html += '<div class="archive-body"><div class="request-list" id="archive-list-' + s.id + '"></div></div>';
html += '</details>';
@@ -1234,8 +1473,7 @@
var sid = el.getAttribute('data-session-id');
var listEl = document.getElementById('archive-list-' + sid);
if (listEl && !listEl.dataset.loaded) {
var aUrl = '/api/sessions/' + sid + '/requests?_=1' + channelParam();
if (AUTH_TOKEN) aUrl += '&token=' + encodeURIComponent(AUTH_TOKEN);
var aUrl = tokenParam('/api/sessions/' + sid + '/requests?_=1' + channelParam());
fetch(aUrl).then(function(r) { return r.text(); }).then(function(h) {
listEl.innerHTML = h || '<div class="empty-state"><p>No played requests</p></div>';
listEl.dataset.loaded = '1';
@@ -1247,6 +1485,64 @@
});
}
function renameSession(sessionId, btn) {
var details = btn.closest('.archived-session');
var currentName = details ? details.querySelector('summary > span').textContent : '';
showModal({
title: 'Rename Session',
body: 'Enter a new name for this session.',
input: true,
inputPlaceholder: currentName,
inputDefault: currentName,
confirmText: 'Rename',
confirmClass: 'btn-approve',
onConfirm: function(name) {
if (!name || !name.trim()) return;
fetch('/api/sessions/' + sessionId, {
method: 'PATCH',
headers: authHeaders(),
body: JSON.stringify({ name: name.trim() })
}).then(function(r) {
if (r.ok) fetchSessions();
});
}
});
}
function clearSession(sessionId) {
showModal({
title: 'Clear Session Requests',
body: 'This will permanently remove all requests from this session. The session itself will remain. Continue?',
confirmText: 'Clear',
confirmClass: 'btn-reject',
onConfirm: function() {
fetch('/api/sessions/' + sessionId + '/clear', {
method: 'POST',
headers: authHeaders()
}).then(function(r) {
if (r.ok) fetchSessions();
});
}
});
}
function deleteSession(sessionId) {
showModal({
title: 'Delete Session',
body: 'This will permanently delete this session and all its requests. This cannot be undone. Continue?',
confirmText: 'Delete',
confirmClass: 'btn-reject',
onConfirm: function() {
fetch('/api/sessions/' + sessionId, {
method: 'DELETE',
headers: authHeaders()
}).then(function(r) {
if (r.ok) fetchSessions();
});
}
});
}
function escapeHtml(str) {
var d = document.createElement('div');
d.textContent = str;
@@ -1259,8 +1555,7 @@
e.stopPropagation();
var url = '/api/export/markdown?_=1';
if (activeChannel) url += '&channel=' + encodeURIComponent(activeChannel);
if (AUTH_TOKEN) url += '&token=' + encodeURIComponent(AUTH_TOKEN);
window.location.href = url;
window.location.href = tokenParam(url);
}
function clearHistory() {
@@ -1270,13 +1565,13 @@
confirmText: 'Clear',
confirmClass: 'btn-reject',
onConfirm: function() {
var url = '/api/history/clear';
if (AUTH_TOKEN) url += '?token=' + encodeURIComponent(AUTH_TOKEN);
fetch(url, { method: 'POST', headers: AUTH_TOKEN ? { 'X-Auth-Token': AUTH_TOKEN } : {} })
.then(function() {
var hl = document.getElementById('history-list');
if (hl) hl.innerHTML = '';
});
fetch(tokenParam('/api/history/clear'), {
method: 'POST',
headers: authHeaders()
}).then(function() {
var hl = document.getElementById('history-list');
if (hl) hl.innerHTML = '';
});
}
});
}
@@ -1285,7 +1580,8 @@
function connectWS() {
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
var url = proto + '//' + location.host + '/ws';
if (AUTH_TOKEN) url += '?token=' + encodeURIComponent(AUTH_TOKEN);
var token = getAuthToken();
if (token) url += '?token=' + encodeURIComponent(token);
ws = new WebSocket(url);
@@ -1310,6 +1606,11 @@
var msg;
try { msg = JSON.parse(e.data); } catch (_) { return; }
if (msg.event === 'admin-presence') {
updatePresence(msg.admins);
return;
}
if (msg.event === 'history-cleared') {
var hl = document.getElementById('history-list');
if (hl) hl.innerHTML = '';
@@ -1385,10 +1686,13 @@
}
// ---- Init ----
fetchChannels();
reloadLists();
fetchSessions();
connectWS();
initAuth().then(function() {
fetchChannels();
reloadLists();
fetchSessions();
fetchInitialStatus();
connectWS();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,241 @@
<!DOCTYPE html>
<html lang="en" data-theme="system">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login - Song Requests</title>
<style>
:root {
--bg: #0f0f0f;
--surface: #1a1a1a;
--surface-hover: #242424;
--border: #2a2a2a;
--text: #e8e8e8;
--text-muted: #888;
--accent: #fa2d48;
--accent-hover: #ff4562;
--approve: #2dd4a0;
--radius: 12px;
}
[data-theme="light"] {
--bg: #f5f5f7;
--surface: #ffffff;
--surface-hover: #f0f0f0;
--border: #e0e0e0;
--text: #1d1d1f;
--text-muted: #6e6e73;
}
@media (prefers-color-scheme: light) {
[data-theme="system"] {
--bg: #f5f5f7;
--surface: #ffffff;
--surface-hover: #f0f0f0;
--border: #e0e0e0;
--text: #1d1d1f;
--text-muted: #6e6e73;
}
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
font-size: 16px;
background: var(--bg);
color: var(--text);
line-height: 1.5;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 2rem;
width: 100%;
max-width: 380px;
margin: 1rem;
}
.login-header {
text-align: center;
margin-bottom: 1.5rem;
}
.login-logo {
width: 48px;
height: 48px;
background: linear-gradient(135deg, var(--accent), #fc6076);
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
margin-bottom: 0.75rem;
}
.login-header h1 {
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.02em;
}
.login-header p {
color: var(--text-muted);
font-size: 0.875rem;
margin-top: 0.25rem;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-muted);
margin-bottom: 0.375rem;
}
.field input {
width: 100%;
padding: 0.6rem 0.75rem;
background: var(--surface-hover);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 0.9375rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.field input:focus { border-color: var(--accent); }
.field input::placeholder { color: var(--text-muted); }
.error-msg {
color: var(--accent);
font-size: 0.8125rem;
font-weight: 500;
margin-bottom: 1rem;
min-height: 1.25rem;
}
.btn-login {
width: 100%;
padding: 0.65rem;
background: var(--accent);
border: none;
border-radius: 8px;
color: #fff;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
font-family: inherit;
}
.btn-login:hover { background: var(--accent-hover); }
.btn-login:disabled { opacity: 0.6; cursor: not-allowed; }
.login-footer {
text-align: center;
margin-top: 1.25rem;
}
.login-footer a {
color: var(--text-muted);
font-size: 0.8125rem;
text-decoration: none;
transition: color 0.15s;
}
.login-footer a:hover { color: var(--text); }
</style>
</head>
<body>
<div class="login-card">
<div class="login-header">
<div class="login-logo">&#9835;</div>
<h1>Admin Login</h1>
<p>Sign in to manage song requests</p>
</div>
<form id="login-form" onsubmit="return handleLogin(event)">
<div class="field">
<label for="username">Username</label>
<input type="text" id="username" name="username" placeholder="Your admin username" autocomplete="username" required />
</div>
<div class="field">
<label for="key">Admin Key</label>
<input type="password" id="key" name="key" placeholder="Your admin key" autocomplete="current-password" required />
</div>
<div class="error-msg" id="error-msg"></div>
<button type="submit" class="btn-login" id="login-btn">Sign In</button>
</form>
<div class="login-footer">
<a href="/">View dashboard (read-only)</a>
</div>
</div>
<script>
(function initTheme() {
var saved = localStorage.getItem('theme') || 'system';
document.documentElement.setAttribute('data-theme', saved);
})();
if (localStorage.getItem('authToken')) {
window.location.href = '/';
}
function handleLogin(e) {
e.preventDefault();
var btn = document.getElementById('login-btn');
var errEl = document.getElementById('error-msg');
var username = document.getElementById('username').value.trim();
var key = document.getElementById('key').value;
if (!username || !key) {
errEl.textContent = 'Both fields are required.';
return false;
}
btn.disabled = true;
btn.textContent = 'Signing in...';
errEl.textContent = '';
fetch('/api/auth/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username: username, key: key})
})
.then(function(r) { return r.json().then(function(d) { return {ok: r.ok, data: d}; }); })
.then(function(res) {
if (res.ok && res.data.token) {
localStorage.setItem('authToken', res.data.token);
localStorage.setItem('adminUsername', res.data.username);
window.location.href = '/';
} else {
errEl.textContent = res.data.error || 'Login failed.';
btn.disabled = false;
btn.textContent = 'Sign In';
}
})
.catch(function() {
errEl.textContent = 'Connection error. Try again.';
btn.disabled = false;
btn.textContent = 'Sign In';
});
return false;
}
</script>
</body>
</html>

View File

@@ -43,7 +43,7 @@ def _render_alternates(request_id: int, alternates_json: str, status: str) -> st
approve_btn = (
f'<button hx-post="/api/requests/{request_id}/approve-alt/{idx}"'
f' hx-swap="outerHTML" hx-target="#request-{request_id}"'
f' class="btn btn-approve btn-sm"'
f' class="btn btn-approve btn-sm admin-only"'
f' onclick="event.stopPropagation();">Approve</button>'
)
items.append(
@@ -76,7 +76,7 @@ def render_request_card(req: SongRequestModel) -> str:
actions = ""
if req.status == "pending":
actions = f"""
<div class="card-actions">
<div class="card-actions admin-only">
<button hx-post="/api/requests/{req.id}/approve"
hx-swap="outerHTML" hx-target="#request-{req.id}"
class="btn btn-approve" onclick="event.stopPropagation()">Approve</button>
@@ -86,7 +86,7 @@ def render_request_card(req: SongRequestModel) -> str:
</div>"""
elif req.status == "approved":
actions = f"""
<div class="card-actions">
<div class="card-actions admin-only">
<button hx-post="/api/requests/{req.id}/played"
hx-swap="outerHTML" hx-target="#request-{req.id}"
class="btn btn-played" onclick="event.stopPropagation()">Mark Played</button>
@@ -130,6 +130,7 @@ class WebServer:
self._thread: Optional[threading.Thread] = None
self._runner: Optional[web.AppRunner] = None
self._ws_clients: weakref.WeakSet = weakref.WeakSet()
self._ws_admin_map: dict = {}
def start(self):
self._loop = asyncio.new_event_loop()
@@ -172,7 +173,11 @@ class WebServer:
async def _start_app(self):
app = web.Application()
app.router.add_get("/", self._handle_dashboard)
app.router.add_get("/login", self._handle_login_page)
app.router.add_get("/ws", self._handle_ws)
app.router.add_post("/api/auth/login", self._handle_auth_login)
app.router.add_post("/api/auth/logout", self._handle_auth_logout)
app.router.add_get("/api/auth/me", self._handle_auth_me)
app.router.add_get("/api/channels", self._handle_channels)
app.router.add_get("/api/requests", self._handle_api_get)
app.router.add_post("/api/requests/{request_id}/approve-alt/{alt_idx}", self._handle_approve_alt)
@@ -185,8 +190,11 @@ class WebServer:
app.router.add_post("/api/sessions/start", self._handle_start_session)
app.router.add_post("/api/sessions/stop", self._handle_stop_session)
app.router.add_get("/api/sessions/{session_id}/requests", self._handle_session_requests)
app.router.add_patch("/api/sessions/{session_id}", self._handle_rename_session)
app.router.add_delete("/api/sessions/{session_id}", self._handle_delete_session)
app.router.add_post("/api/sessions/{session_id}/clear", self._handle_clear_session)
app.router.add_static("/static", STATIC_DIR)
# Catch-all for /{channel} URL routing must be last
# Catch-all for /{channel} URL routing -- must be last
app.router.add_get("/{channel}", self._handle_dashboard)
self._runner = web.AppRunner(app)
@@ -222,13 +230,96 @@ class WebServer:
for ws in dead:
self._ws_clients.discard(ws)
def _check_auth(self, request: web.Request) -> bool:
token = self._get_auth_token()
# ------------------------------------------------------------------
# Auth helpers
# ------------------------------------------------------------------
def _extract_token(self, request: web.Request) -> str:
return (
request.headers.get("X-Auth-Token", "")
or request.query.get("token", "")
)
def _check_auth(self, request: web.Request) -> Optional[str]:
"""Validate request auth. Returns admin username or None.
Accepts either a valid session token or the legacy webAuthToken.
"""
token = self._extract_token(request)
if not token:
return True
q_token = request.query.get("token", "")
h_token = request.headers.get("X-Auth-Token", "")
return q_token == token or h_token == token
return None
username = self._store.validate_session(token)
if username:
return username
legacy = self._get_auth_token()
if legacy and token == legacy:
return "__legacy__"
return None
def _require_auth(self, request: web.Request) -> Optional[web.Response]:
"""Return a 403 Response if auth fails, or None if auth passes."""
if self._check_auth(request) is None:
return web.Response(text="Forbidden", status=403)
return None
def _get_online_admins(self) -> list:
return sorted(set(self._ws_admin_map.values()))
async def _broadcast_presence(self):
admins = self._get_online_admins()
payload = json.dumps({"event": "admin-presence", "admins": admins})
await self._broadcast_raw(payload)
# ------------------------------------------------------------------
# Auth routes
# ------------------------------------------------------------------
async def _handle_login_page(self, request: web.Request) -> web.Response:
template_path = os.path.join(TEMPLATES_DIR, "login.html")
try:
with open(template_path, "r", encoding="utf-8") as f:
content = f.read()
except FileNotFoundError:
return web.Response(text="Login template not found", status=500)
return web.Response(text=content, content_type="text/html")
async def _handle_auth_login(self, request: web.Request) -> web.Response:
try:
data = await request.json()
except Exception:
return web.Response(text="Bad request", status=400)
username = (data.get("username") or "").strip()
key = data.get("key") or ""
if not username or not key:
return web.json_response({"error": "Username and key are required"}, status=400)
admin_id = self._store.validate_admin(username, key)
if admin_id is None:
return web.json_response({"error": "Invalid credentials"}, status=401)
token = self._store.create_session_token(admin_id, username)
return web.json_response({"token": token, "username": username})
async def _handle_auth_logout(self, request: web.Request) -> web.Response:
token = self._extract_token(request)
if token:
self._store.delete_session(token)
return web.json_response({"ok": True})
async def _handle_auth_me(self, request: web.Request) -> web.Response:
token = self._extract_token(request)
username = self._store.validate_session(token) if token else None
if not username:
return web.json_response({"error": "Not authenticated"}, status=401)
return web.json_response({"username": username})
# ------------------------------------------------------------------
# Dashboard & WebSocket
# ------------------------------------------------------------------
async def _handle_dashboard(self, request: web.Request) -> web.Response:
template_path = os.path.join(TEMPLATES_DIR, "index.html")
@@ -237,23 +328,35 @@ class WebServer:
content = f.read()
except FileNotFoundError:
return web.Response(text="Dashboard template not found", status=500)
token = self._get_auth_token()
content = content.replace("{{AUTH_TOKEN}}", html.escape(token or ""))
return web.Response(text=content, content_type="text/html")
async def _handle_ws(self, request: web.Request) -> web.WebSocketResponse:
ws = web.WebSocketResponse()
await ws.prepare(request)
self._ws_clients.add(ws)
token = request.query.get("token", "")
admin_username = self._store.validate_session(token) if token else None
if admin_username:
self._ws_admin_map[ws] = admin_username
await self._broadcast_presence()
try:
async for msg in ws:
if msg.type == aiohttp.WSMsgType.ERROR:
break
finally:
self._ws_clients.discard(ws)
was_admin = ws in self._ws_admin_map
self._ws_admin_map.pop(ws, None)
if was_admin:
await self._broadcast_presence()
return ws
# ------------------------------------------------------------------
# Data API
# ------------------------------------------------------------------
async def _handle_channels(self, request: web.Request) -> web.Response:
channels = self._store.get_channels()
return web.json_response(channels)
@@ -273,8 +376,9 @@ class WebServer:
return web.Response(text=cards, content_type="text/html")
async def _handle_api_action(self, request: web.Request) -> web.Response:
if not self._check_auth(request):
return web.Response(text="Forbidden", status=403)
denied = self._require_auth(request)
if denied:
return denied
try:
request_id = int(request.match_info["request_id"])
@@ -303,8 +407,9 @@ class WebServer:
return web.Response(text=card_html, content_type="text/html")
async def _handle_approve_alt(self, request: web.Request) -> web.Response:
if not self._check_auth(request):
return web.Response(text="Forbidden", status=403)
denied = self._require_auth(request)
if denied:
return denied
try:
request_id = int(request.match_info["request_id"])
@@ -342,8 +447,9 @@ class WebServer:
return web.Response(text=card_html, content_type="text/html")
async def _handle_export_markdown(self, request: web.Request) -> web.Response:
if not self._check_auth(request):
return web.Response(text="Forbidden", status=403)
denied = self._require_auth(request)
if denied:
return denied
channel_filter = request.query.get("channel") or None
reqs = self._store.get_history(limit=5000, channel=channel_filter)
@@ -367,8 +473,9 @@ class WebServer:
return web.json_response({"open": is_open})
async def _handle_post_status(self, request: web.Request) -> web.Response:
if not self._check_auth(request):
return web.Response(text="Forbidden", status=403)
denied = self._require_auth(request)
if denied:
return denied
try:
data = await request.json()
@@ -389,8 +496,9 @@ class WebServer:
return web.json_response({"open": is_open})
async def _handle_clear_history(self, request: web.Request) -> web.Response:
if not self._check_auth(request):
return web.Response(text="Forbidden", status=403)
denied = self._require_auth(request)
if denied:
return denied
count = self._store.clear_history()
payload = json.dumps({"event": "history-cleared"})
@@ -422,8 +530,9 @@ class WebServer:
return web.json_response(result)
async def _handle_start_session(self, request: web.Request) -> web.Response:
if not self._check_auth(request):
return web.Response(text="Forbidden", status=403)
denied = self._require_auth(request)
if denied:
return denied
existing = self._store.get_active_session()
if existing:
@@ -444,8 +553,9 @@ class WebServer:
return web.json_response(session.to_dict())
async def _handle_stop_session(self, request: web.Request) -> web.Response:
if not self._check_auth(request):
return web.Response(text="Forbidden", status=403)
denied = self._require_auth(request)
if denied:
return denied
active = self._store.get_active_session()
if not active:
@@ -476,3 +586,57 @@ class WebServer:
reqs = self._store.get_session_history(session_id, channel=channel_filter)
cards = "\n".join(render_request_card(r) for r in reqs)
return web.Response(text=cards, content_type="text/html")
async def _handle_rename_session(self, request: web.Request) -> web.Response:
denied = self._require_auth(request)
if denied:
return denied
try:
session_id = int(request.match_info["session_id"])
except (ValueError, KeyError):
return web.Response(text="Bad request", status=400)
try:
data = await request.json()
except Exception:
return web.Response(text="Bad request", status=400)
name = data.get("name", "").strip()
if not name:
return web.json_response({"error": "Name cannot be empty"}, status=400)
session = self._store.rename_session(session_id, name)
if not session:
return web.json_response({"error": "Session not found"}, status=404)
return web.json_response(session.to_dict())
async def _handle_clear_session(self, request: web.Request) -> web.Response:
denied = self._require_auth(request)
if denied:
return denied
try:
session_id = int(request.match_info["session_id"])
except (ValueError, KeyError):
return web.Response(text="Bad request", status=400)
count = self._store.clear_session_requests(session_id)
return web.json_response({"cleared": count})
async def _handle_delete_session(self, request: web.Request) -> web.Response:
denied = self._require_auth(request)
if denied:
return denied
try:
session_id = int(request.match_info["session_id"])
except (ValueError, KeyError):
return web.Response(text="Bad request", status=400)
deleted = self._store.delete_session(session_id)
if not deleted:
return web.json_response({"error": "Session not found"}, status=404)
return web.json_response({"deleted": True})