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:
258
.cursor/plans/apple_music_integration_a6a44bfc.plan.md
Normal file
258
.cursor/plans/apple_music_integration_a6a44bfc.plan.md
Normal 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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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">♫</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">☾</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)">✎</button>'
|
||||
+ '<button class="btn-icon" title="Clear requests" onclick="event.stopPropagation(); clearSession(' + s.id + ', this)">🗑</button>'
|
||||
+ '<button class="btn-icon btn-danger" title="Delete session" onclick="event.stopPropagation(); deleteSession(' + s.id + ', this)">✕</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>
|
||||
|
||||
241
SongRequest/templates/login.html
Normal file
241
SongRequest/templates/login.html
Normal 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">♫</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>
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user