diff --git a/src/database.js b/src/database.js index 53c6a5d9..01e3e2be 100644 --- a/src/database.js +++ b/src/database.js @@ -39,7 +39,7 @@ class Database { database: Config.get('mysql.database'), multipleStatements: true, // Legacy thing charset: 'utf8mb4' - } + }; } else { connection = { host: Config.get('mysql.server'), @@ -49,9 +49,9 @@ class Database { database: Config.get('mysql.database'), multipleStatements: true, // Legacy thing charset: 'utf8mb4' - } + }; } - + knexConfig = { client: 'mysql', connection, diff --git a/src/shows.js b/src/shows.js index b7d0d3fe..7fa04864 100644 --- a/src/shows.js +++ b/src/shows.js @@ -22,15 +22,93 @@ function makeSystemProxy(name) { function computeNextRunAt(show) { const base = Number(show.next_run_at || show.scheduled_for || Date.now()); - if (show.recurrence === 'daily') { - return base + 24 * 60 * 60 * 1000; + const recurrence = show.recurrence; + const timezone = show.timezone || 'UTC'; + + if (recurrence !== 'daily' && recurrence !== 'weekly') { + return base; } - if (show.recurrence === 'weekly') { - return base + 7 * 24 * 60 * 60 * 1000; + const daysToAdd = recurrence === 'weekly' ? 7 : 1; + const source = new Date(base); + const local = toZonedParts(source, timezone); + if (!local) { + return base + (daysToAdd * 24 * 60 * 60 * 1000); } - return base; + const targetDate = addDaysUTC(local.year, local.month, local.day, daysToAdd); + const zonedTarget = { + year: targetDate.year, + month: targetDate.month, + day: targetDate.day, + hour: local.hour, + minute: local.minute, + second: local.second + }; + + const next = zonedDateTimeToUtc(zonedTarget, timezone); + return next || (base + (daysToAdd * 24 * 60 * 60 * 1000)); +} + +function toZonedParts(date, timezone) { + try { + const dtf = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }); + const parts = dtf.formatToParts(date); + const out = {}; + for (const part of parts) { + if (part.type === 'literal') continue; + out[part.type] = parseInt(part.value, 10); + } + + return { + year: out.year, + month: out.month, + day: out.day, + hour: out.hour, + minute: out.minute, + second: out.second + }; + } catch (_err) { + return null; + } +} + +function addDaysUTC(year, month, day, days) { + const d = new Date(Date.UTC(year, month - 1, day + days)); + return { + year: d.getUTCFullYear(), + month: d.getUTCMonth() + 1, + day: d.getUTCDate() + }; +} + +function zonedDateTimeToUtc(local, timezone) { + let guess = Date.UTC(local.year, local.month - 1, local.day, local.hour, local.minute, local.second); + + // Iterate to resolve timezone offset for the target wall-clock time (handles DST shifts). + for (let i = 0; i < 4; i++) { + const zoned = toZonedParts(new Date(guess), timezone); + if (!zoned) return null; + + const desired = Date.UTC(local.year, local.month - 1, local.day, local.hour, local.minute, local.second); + const current = Date.UTC(zoned.year, zoned.month - 1, zoned.day, zoned.hour, zoned.minute, zoned.second); + const delta = desired - current; + if (delta === 0) { + return guess; + } + guess += delta; + } + + return guess; } function normalizePlaylist(rawPlaylist) { diff --git a/src/web/routes/api/emotes.js b/src/web/routes/api/emotes.js index 7f8b2dd0..183e2c61 100644 --- a/src/web/routes/api/emotes.js +++ b/src/web/routes/api/emotes.js @@ -26,8 +26,11 @@ async function getChannelEmotes(channelId) { (err, rows) => { if (err) return reject(new Error(err)); if (!rows || rows.length === 0) return resolve([]); - try { resolve(JSON.parse(rows[0].value)); } - catch (e) { resolve([]); } + try { + resolve(JSON.parse(rows[0].value)); + } catch (e) { + resolve([]); + } } ); }); @@ -92,7 +95,6 @@ router.put('/:name', botAuth, requireRank(4), async (req, res) => { return res.status(409).json({ error: 'An emote with that name already exists' }); } - const old = emotes[idx]; emotes[idx] = validated; await saveChannelEmotes(req.bot.channel_id, emotes); diff --git a/src/web/routes/api/middleware.js b/src/web/routes/api/middleware.js index 229ce475..8c947aec 100644 --- a/src/web/routes/api/middleware.js +++ b/src/web/routes/api/middleware.js @@ -63,6 +63,7 @@ async function getChannelRow(channelName) { return new Promise((resolve, reject) => { db.channels.lookup(channelName, (err, row) => { if (err) reject(new Error(err)); + else if (!row) reject(new Error('Channel not found')); else resolve(row); }); }); diff --git a/src/web/routes/api/shows.js b/src/web/routes/api/shows.js index d66fad49..b4ada722 100644 --- a/src/web/routes/api/shows.js +++ b/src/web/routes/api/shows.js @@ -138,6 +138,11 @@ async function authorizeChannel(req, res) { return null; } + if (bot.rank < 2) { + res.status(403).json({ error: 'Insufficient rank' }); + return null; + } + return { actorName: bot.name, rank: bot.rank,