From 56ab732f6b8d90d6d11284ea8314505364cf600d Mon Sep 17 00:00:00 2001 From: Speng Reb Date: Wed, 20 May 2026 21:00:48 +0200 Subject: [PATCH] Better handling of TZ and Bot API added --- docs/bot-api.md | 71 +++++++++++++++++++++++++++++++ src/web/routes/api/shows.js | 82 +++++++++++++++++++++++++++++++----- templates/channeloptions.pug | 2 +- www/js/ui.js | 43 ++++++++++++++++++- 4 files changed, 185 insertions(+), 13 deletions(-) diff --git a/docs/bot-api.md b/docs/bot-api.md index f07937d0..68dfc37f 100644 --- a/docs/bot-api.md +++ b/docs/bot-api.md @@ -362,6 +362,77 @@ Update one or more settings. Unknown keys are silently ignored. --- +### Shows + +Show endpoints manage scheduled playlist runs. These endpoints support bot Bearer auth and session auth. + +#### `GET /channels/:channel/shows` + +List shows for the channel. Minimum rank: **2 (Mod)**. + +#### `GET /channels/:channel/shows/:id` + +Get a single show. Minimum rank: **2 (Mod)**. + +#### `POST /channels/:channel/shows` + +Create a show. Minimum rank: **2 (Mod)**. + +#### `PUT /channels/:channel/shows/:id` + +Update a show. Minimum rank: **2 (Mod)**. + +#### `DELETE /channels/:channel/shows/:id` + +Delete a show. Minimum rank: **3 (Admin)**. + +#### `POST /channels/:channel/shows/:id/action` + +Run control action. + +| Action | Minimum rank | +|------------|--------------| +| `pause` | 2 | +| `resume` | 2 | +| `schedule` | 2 | +| `run` | 3 | +| `cancel` | 3 | + +**Create/Update body schema:** + +```json +{ + "name": "Friday Prime", + "scheduled_for": "2026-05-22T19:00:00.000Z", + "timezone": "America/New_York", + "recurrence": "weekly", + "fill_mode": "replace", + "conflict_mode": "force", + "start_playback": true, + "playlist": [ + { "type": "yt", "id": "dQw4w9WgXcQ", "pos": "end" } + ], + "status": "scheduled" +} +``` + +**Field constraints:** + +- `timezone`: required IANA timezone string (example: `Europe/Berlin`, `America/New_York`) +- `recurrence`: `none | daily | weekly` +- `fill_mode`: `append | replace` +- `conflict_mode`: `force | skip` +- `playlist`: non-empty array of media entries (`type`, `id`, optional `pos: next|end`) +- `status`: one of `draft | scheduled | paused | completed | failed | canceled` (`running` is internal) + +**Action body schema:** + +```json +{ "action": "run" } +``` + +--- + ### Bot management These endpoints use **session cookie auth** (the normal logged-in web session), not a bot token. They are intended for the channel settings UI. diff --git a/src/web/routes/api/shows.js b/src/web/routes/api/shows.js index 55334acd..bc1ef793 100644 --- a/src/web/routes/api/shows.js +++ b/src/web/routes/api/shows.js @@ -2,7 +2,8 @@ const express = require('express'); const webserver = require('../../webserver'); const showDB = require('../../../database/shows'); const shows = require('../../../shows'); -const { getChannelRow, getUserEffectiveRank } = require('./middleware'); +const botDB = require('../../../database/bots'); +const { getChannelRow, getUserEffectiveRank, hashToken } = require('./middleware'); const router = express.Router({ mergeParams: true }); @@ -10,6 +11,13 @@ const SHOW_STATUSES = new Set(['draft', 'scheduled', 'paused', 'running', 'compl const RECURRENCES = new Set(['none', 'daily', 'weekly']); const FILL_MODES = new Set(['append', 'replace']); const CONFLICT_MODES = new Set(['force', 'skip']); +const ACTION_MIN_RANK = { + pause: 2, + resume: 2, + schedule: 2, + run: 3, + cancel: 3 +}; function sanitizePlaylist(list) { if (!Array.isArray(list)) return []; @@ -28,6 +36,16 @@ function parseSchedule(input) { return ms; } +function isValidTimeZone(tz) { + if (!tz || typeof tz !== 'string') return false; + try { + Intl.DateTimeFormat('en-US', { timeZone: tz }).format(new Date()); + return true; + } catch (_err) { + return false; + } +} + function validateShowPayload(body, old = null) { const name = (body.name || (old && old.name) || '').trim(); if (!name || name.length > 100) { @@ -40,6 +58,9 @@ function validateShowPayload(body, old = null) { } const timezone = String(body.timezone || (old && old.timezone) || 'UTC').trim(); + if (!isValidTimeZone(timezone)) { + return { error: 'timezone must be a valid IANA time zone string' }; + } const scheduledInput = body.scheduled_for !== undefined ? body.scheduled_for : (old ? old.scheduled_for : null); const scheduledFor = typeof scheduledInput === 'number' ? scheduledInput : parseSchedule(scheduledInput); if (!scheduledFor) { @@ -96,6 +117,33 @@ function validateShowPayload(body, old = null) { } async function authorizeChannel(req, res) { + const authHeader = req.headers['authorization']; + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.slice(7).trim(); + if (!token.startsWith('cbt_')) { + res.status(401).json({ error: 'Invalid token format' }); + return null; + } + + const tokenHash = hashToken(token); + const bot = await botDB.getBotByTokenHash(tokenHash); + if (!bot) { + res.status(401).json({ error: 'Invalid or revoked token' }); + return null; + } + + if (bot.channel_name.toLowerCase() !== req.params.channel.toLowerCase()) { + res.status(403).json({ error: 'Token not authorized for this channel' }); + return null; + } + + return { + actorName: bot.name, + rank: bot.rank, + channelRow: { id: bot.channel_id, name: bot.channel_name } + }; + } + const user = await webserver.authorize(req); if (!user) { res.status(401).json({ error: 'Unauthorized' }); @@ -116,7 +164,7 @@ async function authorizeChannel(req, res) { return null; } - return { user, channelRow, rank }; + return { user, actorName: user.name, channelRow, rank }; } router.get('/', async (req, res) => { @@ -127,6 +175,17 @@ router.get('/', async (req, res) => { res.json(showsList); }); +router.get('/:id', async (req, res) => { + const auth = await authorizeChannel(req, res); + if (!auth) return; + + const id = parseInt(req.params.id, 10); + if (isNaN(id)) return res.status(400).json({ error: 'Invalid show id' }); + const show = await showDB.getShowById(id, auth.channelRow.id); + if (!show) return res.status(404).json({ error: 'Show not found' }); + res.json(show); +}); + router.post('/', async (req, res) => { const auth = await authorizeChannel(req, res); if (!auth) return; @@ -136,7 +195,7 @@ router.post('/', async (req, res) => { const id = await showDB.createShow({ channelId: auth.channelRow.id, - createdBy: auth.user.name, + createdBy: auth.actorName, input: validated.value }); @@ -162,7 +221,7 @@ router.put('/:id', async (req, res) => { channelId: auth.channelRow.id, input: { ...validated.value, - updated_by: auth.user.name + updated_by: auth.actorName } }); @@ -173,6 +232,7 @@ router.put('/:id', async (req, res) => { router.delete('/:id', async (req, res) => { const auth = await authorizeChannel(req, res); if (!auth) return; + if (auth.rank < 3) return res.status(403).json({ error: 'Insufficient rank' }); const id = parseInt(req.params.id, 10); if (isNaN(id)) return res.status(400).json({ error: 'Invalid show id' }); @@ -196,13 +256,15 @@ router.post('/:id/action', async (req, res) => { const action = String((req.body && req.body.action) || '').toLowerCase(); if (!action) return res.status(400).json({ error: 'action is required' }); + if (!ACTION_MIN_RANK[action]) return res.status(400).json({ error: 'Unknown action' }); + if (auth.rank < ACTION_MIN_RANK[action]) return res.status(403).json({ error: 'Insufficient rank' }); if (action === 'pause') { await showDB.updateShowStatus({ id, channelId: auth.channelRow.id, status: 'paused', - updatedBy: auth.user.name + updatedBy: auth.actorName }); } else if (action === 'resume') { await showDB.updateShow({ @@ -212,7 +274,7 @@ router.post('/:id/action', async (req, res) => { ...show, status: 'scheduled', next_run_at: Date.now(), - updated_by: auth.user.name + updated_by: auth.actorName } }); } else if (action === 'cancel') { @@ -220,7 +282,7 @@ router.post('/:id/action', async (req, res) => { id, channelId: auth.channelRow.id, status: 'canceled', - updatedBy: auth.user.name + updatedBy: auth.actorName }); } else if (action === 'run') { try { @@ -235,7 +297,7 @@ router.post('/:id/action', async (req, res) => { id, recurrence: show.recurrence, nextRunAt: nextRun, - updatedBy: auth.user.name + updatedBy: auth.actorName }); } catch (error) { return res.status(400).json({ error: error.message || 'Failed to execute show' }); @@ -248,11 +310,9 @@ router.post('/:id/action', async (req, res) => { ...show, status: 'scheduled', next_run_at: show.scheduled_for, - updated_by: auth.user.name + updated_by: auth.actorName } }); - } else { - return res.status(400).json({ error: 'Unknown action' }); } const row = await showDB.getShowById(id, auth.channelRow.id); diff --git a/templates/channeloptions.pug b/templates/channeloptions.pug index 61274272..0a51f608 100644 --- a/templates/channeloptions.pug +++ b/templates/channeloptions.pug @@ -269,7 +269,7 @@ mixin shows .form-group label.control-label.col-sm-3(for="cs-shows-timezone") Timezone .col-sm-9 - input#cs-shows-timezone.form-control(type="text", placeholder="e.g. Europe/Berlin or America/New_York") + select#cs-shows-timezone.form-control .form-group label.control-label.col-sm-3(for="cs-shows-recurrence") Recurrence .col-sm-9 diff --git a/www/js/ui.js b/www/js/ui.js index 070fde34..13f864b9 100644 --- a/www/js/ui.js +++ b/www/js/ui.js @@ -1177,11 +1177,43 @@ var CSTBots = (function () { var CSTShows = (function () { var selectedId = null; var draftPlaylist = []; + var timezoneOptionsLoaded = false; function apiBase() { return '/api/v1/channels/' + CHANNEL.name + '/shows'; } + function loadTimezoneOptions() { + if (timezoneOptionsLoaded) return; + timezoneOptionsLoaded = true; + var select = $('#cs-shows-timezone').empty(); + var tzs = []; + if (typeof Intl !== 'undefined' && typeof Intl.supportedValuesOf === 'function') { + try { + tzs = Intl.supportedValuesOf('timeZone') || []; + } catch (_err) { + tzs = []; + } + } + if (!tzs.length) { + tzs = [ + 'UTC', + 'Europe/Berlin', + 'Europe/London', + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'Asia/Tokyo', + 'Asia/Kolkata', + 'Australia/Sydney' + ]; + } + tzs.forEach(function (tz) { + $('