diff --git a/config.template.yaml b/config.template.yaml index fddbec1a..08b02398 100644 --- a/config.template.yaml +++ b/config.template.yaml @@ -215,3 +215,14 @@ twitch-client-id: null poll: max-options: 50 + +calendar-sync: + enabled: false + # 32-byte key encoded as base64. Prefer setting via env CALENDAR_SYNC_ENCRYPTION_KEY. + encryption-key: '' + google: + client-id: '' + client-secret: '' + # Must exactly match Google OAuth redirect URI + # Example: https://your-domain/api/v1/integrations/google/callback + redirect-uri: '' diff --git a/src/config.js b/src/config.js index dd6b32aa..ba71df9b 100644 --- a/src/config.js +++ b/src/config.js @@ -109,6 +109,15 @@ var defaults = { "twitch-client-id": null, poll: { "max-options": 50 + }, + "calendar-sync": { + enabled: false, + "encryption-key": "", + google: { + "client-id": "", + "client-secret": "", + "redirect-uri": "" + } } }; diff --git a/src/database/calendar-integrations.js b/src/database/calendar-integrations.js new file mode 100644 index 00000000..07027647 --- /dev/null +++ b/src/database/calendar-integrations.js @@ -0,0 +1,174 @@ +const db = require('../database'); + +function knex() { + return db.getDB().knex; +} + +function parseIntegrationRow(row) { + if (!row) return null; + let config = {}; + try { + config = row.config_json ? JSON.parse(row.config_json) : {}; + } catch (_err) { + config = {}; + } + + return { + id: row.id, + channel_id: row.channel_id, + provider: row.provider, + status: row.status, + config, + token_encrypted: row.token_encrypted || null, + refresh_token_encrypted: row.refresh_token_encrypted || null, + token_expires_at: row.token_expires_at || null, + last_sync_at: row.last_sync_at || null, + last_error: row.last_error || null, + connected_by: row.connected_by || null, + updated_by: row.updated_by || null, + created_at: row.created_at, + updated_at: row.updated_at + }; +} + +async function listByChannel(channelId) { + const rows = await knex()('channel_calendar_integrations') + .where({ channel_id: channelId }) + .orderBy('provider', 'asc') + .select(); + return rows.map(parseIntegrationRow); +} + +async function getByChannelProvider(channelId, provider) { + const rows = await knex()('channel_calendar_integrations') + .where({ channel_id: channelId, provider }) + .limit(1) + .select(); + return parseIntegrationRow(rows[0]); +} + +async function upsertGoogleIntegration(channelId, payload) { + const now = Date.now(); + const existing = await getByChannelProvider(channelId, 'google'); + const patch = { + status: payload.status || 'connected', + config_json: JSON.stringify(payload.config || {}), + token_encrypted: payload.token_encrypted || null, + refresh_token_encrypted: payload.refresh_token_encrypted || null, + token_expires_at: payload.token_expires_at || null, + last_error: payload.last_error || null, + connected_by: payload.connected_by || null, + updated_by: payload.updated_by || null, + updated_at: now + }; + + if (!existing) { + const [id] = await knex()('channel_calendar_integrations').insert({ + channel_id: channelId, + provider: 'google', + created_at: now, + ...patch + }); + return id; + } + + await knex()('channel_calendar_integrations') + .where({ id: existing.id }) + .update(patch); + return existing.id; +} + +async function disconnectIntegration(id, channelId, updatedBy) { + await knex()('channel_calendar_integrations') + .where({ id, channel_id: channelId }) + .update({ + status: 'disconnected', + token_encrypted: null, + refresh_token_encrypted: null, + token_expires_at: null, + last_error: null, + updated_by: updatedBy || null, + updated_at: Date.now() + }); +} + +async function updateIntegrationSyncResult(id, patch) { + const update = { + status: patch.status || 'connected', + last_sync_at: patch.last_sync_at || Date.now(), + last_error: patch.last_error || null, + updated_at: Date.now() + }; + if (Object.prototype.hasOwnProperty.call(patch, 'token_encrypted')) { + update.token_encrypted = patch.token_encrypted; + } + if (Object.prototype.hasOwnProperty.call(patch, 'refresh_token_encrypted')) { + update.refresh_token_encrypted = patch.refresh_token_encrypted; + } + if (Object.prototype.hasOwnProperty.call(patch, 'token_expires_at')) { + update.token_expires_at = patch.token_expires_at; + } + + await knex()('channel_calendar_integrations') + .where({ id }) + .update(update); +} + +function parseExternalRow(row) { + if (!row) return null; + return { + id: row.id, + channel_id: row.channel_id, + show_id: row.show_id, + integration_id: row.integration_id, + provider: row.provider, + external_event_id: row.external_event_id, + external_etag: row.external_etag || null + }; +} + +async function getExternalEvent(showId, integrationId) { + const rows = await knex()('channel_show_external_events') + .where({ show_id: showId, integration_id: integrationId }) + .limit(1) + .select(); + return parseExternalRow(rows[0]); +} + +async function upsertExternalEvent({ channelId, showId, integrationId, provider, externalEventId, externalEtag }) { + const now = Date.now(); + const existing = await getExternalEvent(showId, integrationId); + if (!existing) { + await knex()('channel_show_external_events').insert({ + channel_id: channelId, + show_id: showId, + integration_id: integrationId, + provider, + external_event_id: externalEventId, + external_etag: externalEtag || null, + last_pushed_at: now, + created_at: now, + updated_at: now + }); + return; + } + + await knex()('channel_show_external_events') + .where({ id: existing.id }) + .update({ + external_event_id: externalEventId, + external_etag: externalEtag || null, + last_pushed_at: now, + updated_at: now + }); +} + +module.exports = { + listByChannel, + getByChannelProvider, + upsertGoogleIntegration, + disconnectIntegration, + updateIntegrationSyncResult, + getExternalEvent, + upsertExternalEvent +}; diff --git a/src/database/tables.js b/src/database/tables.js index 0737c857..e4794d24 100644 --- a/src/database/tables.js +++ b/src/database/tables.js @@ -217,4 +217,56 @@ export async function initTables() { t.string('banned_by', 20).notNullable(); t.timestamps(/* useTimestamps */ true, /* defaultToNow */ true); }); + + await ensureTable('channel_calendar_integrations', t => { + t.charset('utf8'); + t.increments('id').notNullable().primary(); + t.integer('channel_id') + .unsigned() + .notNullable() + .references('id').inTable('channels') + .onDelete('cascade'); + t.string('provider', 32).notNullable(); + t.string('status', 20).notNullable().defaultTo('disconnected'); + t.specificType('config_json', 'text character set utf8mb4'); + t.specificType('token_encrypted', 'text character set utf8mb4'); + t.specificType('refresh_token_encrypted', 'text character set utf8mb4'); + t.bigInteger('token_expires_at').nullable(); + t.bigInteger('last_sync_at').nullable(); + t.specificType('last_error', 'text character set utf8mb4'); + t.bigInteger('created_at').notNullable(); + t.bigInteger('updated_at').notNullable(); + t.string('connected_by', 20).nullable(); + t.string('updated_by', 20).nullable(); + t.unique(['channel_id', 'provider'], 'channel_calendar_integration_unique'); + t.index(['channel_id', 'provider'], 'channel_calendar_integration_lookup'); + }); + + await ensureTable('channel_show_external_events', t => { + t.charset('utf8'); + t.increments('id').notNullable().primary(); + t.integer('channel_id') + .unsigned() + .notNullable() + .references('id').inTable('channels') + .onDelete('cascade'); + t.integer('show_id') + .unsigned() + .notNullable() + .references('id').inTable('channel_shows') + .onDelete('cascade'); + t.integer('integration_id') + .unsigned() + .notNullable() + .references('id').inTable('channel_calendar_integrations') + .onDelete('cascade'); + t.string('provider', 32).notNullable(); + t.string('external_event_id', 255).notNullable(); + t.string('external_etag', 255).nullable(); + t.bigInteger('last_pushed_at').nullable(); + t.bigInteger('created_at').notNullable(); + t.bigInteger('updated_at').notNullable(); + t.unique(['show_id', 'integration_id'], 'channel_show_external_event_unique'); + t.index(['integration_id', 'provider'], 'channel_show_external_event_integration_idx'); + }); } diff --git a/src/database/update.js b/src/database/update.js index fa73e272..8e7bb848 100644 --- a/src/database/update.js +++ b/src/database/update.js @@ -3,7 +3,7 @@ import Promise from 'bluebird'; const LOGGER = require('@calzoneman/jsli')('database/update'); -const DB_VERSION = 13; +const DB_VERSION = 15; var hasUpdates = []; module.exports.checkVersion = function () { @@ -55,6 +55,10 @@ function update(version, cb) { addUserInactiveColumn(cb); } else if (version < 13) { addShowsNotesAndColorColumns(cb); + } else if (version < 14) { + addCalendarIntegrationTables(cb); + } else if (version < 15) { + addCalendarIntegrationAuditColumns(cb); } } @@ -169,3 +173,88 @@ function addShowsNotesAndColorColumns(cb) { } ); } + +function addCalendarIntegrationTables(cb) { + db.query( + "CREATE TABLE IF NOT EXISTS channel_calendar_integrations (" + + "id INT NOT NULL AUTO_INCREMENT PRIMARY KEY," + + "channel_id INT UNSIGNED NOT NULL," + + "provider VARCHAR(32) NOT NULL," + + "status VARCHAR(20) NOT NULL DEFAULT 'disconnected'," + + "config_json TEXT CHARACTER SET utf8mb4 NULL," + + "token_encrypted TEXT CHARACTER SET utf8mb4 NULL," + + "refresh_token_encrypted TEXT CHARACTER SET utf8mb4 NULL," + + "token_expires_at BIGINT NULL," + + "last_sync_at BIGINT NULL," + + "last_error TEXT CHARACTER SET utf8mb4 NULL," + + "created_at BIGINT NOT NULL," + + "updated_at BIGINT NOT NULL," + + "connected_by VARCHAR(20) NULL," + + "updated_by VARCHAR(20) NULL," + + "UNIQUE KEY channel_calendar_integration_unique (channel_id, provider)," + + "KEY channel_calendar_integration_lookup (channel_id, provider)," + + "CONSTRAINT fk_calendar_integrations_channel FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE" + + ") CHARACTER SET utf8", + error => { + if (error) { + LOGGER.error(`Failed to create channel_calendar_integrations table: ${error}`); + cb(error); + return; + } + + db.query( + "CREATE TABLE IF NOT EXISTS channel_show_external_events (" + + "id INT NOT NULL AUTO_INCREMENT PRIMARY KEY," + + "channel_id INT UNSIGNED NOT NULL," + + "show_id INT UNSIGNED NOT NULL," + + "integration_id INT UNSIGNED NOT NULL," + + "provider VARCHAR(32) NOT NULL," + + "external_event_id VARCHAR(255) NOT NULL," + + "external_etag VARCHAR(255) NULL," + + "last_pushed_at BIGINT NULL," + + "created_at BIGINT NOT NULL," + + "updated_at BIGINT NOT NULL," + + "UNIQUE KEY channel_show_external_event_unique (show_id, integration_id)," + + "KEY channel_show_external_event_integration_idx (integration_id, provider)," + + "CONSTRAINT fk_external_events_channel FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE," + + "CONSTRAINT fk_external_events_show FOREIGN KEY (show_id) REFERENCES channel_shows(id) ON DELETE CASCADE," + + "CONSTRAINT fk_external_events_integration FOREIGN KEY (integration_id) REFERENCES channel_calendar_integrations(id) ON DELETE CASCADE" + + ") CHARACTER SET utf8", + error => { + if (error) { + LOGGER.error(`Failed to create channel_show_external_events table: ${error}`); + cb(error); + return; + } + + cb(); + } + ); + } + ); +} + +function addCalendarIntegrationAuditColumns(cb) { + db.query( + "ALTER TABLE channel_calendar_integrations ADD COLUMN connected_by VARCHAR(20) NULL", + error => { + if (error) { + LOGGER.error(`Failed to add connected_by column: ${error}`); + cb(error); + return; + } + + db.query( + "ALTER TABLE channel_calendar_integrations ADD COLUMN updated_by VARCHAR(20) NULL", + error => { + if (error) { + LOGGER.error(`Failed to add updated_by column: ${error}`); + cb(error); + return; + } + cb(); + } + ); + } + ); +} diff --git a/src/integrations/google-calendar.js b/src/integrations/google-calendar.js new file mode 100644 index 00000000..7d22d9f9 --- /dev/null +++ b/src/integrations/google-calendar.js @@ -0,0 +1,267 @@ +const https = require('https'); +const querystring = require('querystring'); +const Config = require('../config'); +const { encryptString, decryptString } = require('../util/secretbox'); + +function getIntegrationConfig() { + return Config.get('calendar-sync'); +} + +function isEnabled() { + const cfg = getIntegrationConfig(); + return !!cfg.enabled; +} + +function getEncryptionKey() { + return process.env.CALENDAR_SYNC_ENCRYPTION_KEY || + Config.get('calendar-sync.encryption-key') || + ''; +} + +function getGoogleConfig() { + return Config.get('calendar-sync.google'); +} + +function assertConfigured() { + if (!isEnabled()) { + throw new Error('Calendar sync is disabled by config'); + } + const google = getGoogleConfig(); + if (!google['client-id'] || !google['client-secret'] || !google['redirect-uri']) { + throw new Error('Google calendar sync config is incomplete'); + } + if (!getEncryptionKey()) { + throw new Error('Calendar sync encryption key is not configured'); + } +} + +function encodeState(payload, cookieSecret) { + const crypto = require('crypto'); + const raw = Buffer.from(JSON.stringify(payload)).toString('base64'); + const sig = crypto.createHmac('sha256', cookieSecret).update(raw).digest('hex'); + return `${raw}.${sig}`; +} + +function decodeState(state, cookieSecret) { + const crypto = require('crypto'); + if (!state || typeof state !== 'string' || state.indexOf('.') === -1) { + throw new Error('Invalid OAuth state'); + } + const parts = state.split('.'); + const raw = parts[0]; + const sig = parts[1]; + const expected = crypto.createHmac('sha256', cookieSecret).update(raw).digest('hex'); + if (sig !== expected) { + throw new Error('Invalid OAuth state signature'); + } + const payload = JSON.parse(Buffer.from(raw, 'base64').toString('utf8')); + if (!payload || payload.exp < Date.now()) { + throw new Error('OAuth state expired'); + } + return payload; +} + +function buildAuthUrl(state) { + assertConfigured(); + const google = getGoogleConfig(); + const qs = querystring.stringify({ + client_id: google['client-id'], + redirect_uri: google['redirect-uri'], + response_type: 'code', + scope: 'https://www.googleapis.com/auth/calendar.events', + access_type: 'offline', + prompt: 'consent', + state + }); + return `https://accounts.google.com/o/oauth2/v2/auth?${qs}`; +} + +function requestJson(method, baseUrl, path, body, headers = {}) { + return new Promise((resolve, reject) => { + const rawBody = body ? JSON.stringify(body) : null; + const req = https.request(baseUrl + path, { + method, + headers: Object.assign( + { + Accept: 'application/json' + }, + rawBody ? { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(rawBody) + } : {}, + headers + ) + }, res => { + const chunks = []; + res.on('data', c => chunks.push(c)); + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + const ok = res.statusCode >= 200 && res.statusCode < 300; + let parsed = {}; + try { + parsed = text ? JSON.parse(text) : {}; + } catch (_err) { + if (!ok) { + return reject(new Error(`HTTP ${res.statusCode}: ${text}`)); + } + parsed = {}; + } + if (!ok) { + let message = parsed.error_description || parsed.error || `HTTP ${res.statusCode}`; + if (message && typeof message === 'object') { + message = message.message || message.status || JSON.stringify(message); + } + return reject(new Error(String(message))); + } + resolve(parsed); + }); + }); + req.on('error', reject); + if (rawBody) req.write(rawBody); + req.end(); + }); +} + +function requestForm(baseUrl, path, formBody) { + return new Promise((resolve, reject) => { + const raw = querystring.stringify(formBody); + const req = https.request(baseUrl + path, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': Buffer.byteLength(raw), + Accept: 'application/json' + } + }, res => { + const chunks = []; + res.on('data', c => chunks.push(c)); + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + const ok = res.statusCode >= 200 && res.statusCode < 300; + let parsed = {}; + try { + parsed = text ? JSON.parse(text) : {}; + } catch (_err) { + return reject(new Error(`Google token parse error: ${text}`)); + } + if (!ok) { + let message = parsed.error_description || parsed.error || `HTTP ${res.statusCode}`; + if (message && typeof message === 'object') { + message = message.message || message.status || JSON.stringify(message); + } + return reject(new Error(String(message))); + } + resolve(parsed); + }); + }); + req.on('error', reject); + req.write(raw); + req.end(); + }); +} + +async function exchangeCodeForToken(code) { + assertConfigured(); + const google = getGoogleConfig(); + return requestForm('https://oauth2.googleapis.com', '/token', { + code, + client_id: google['client-id'], + client_secret: google['client-secret'], + redirect_uri: google['redirect-uri'], + grant_type: 'authorization_code' + }); +} + +async function refreshAccessToken(refreshToken) { + assertConfigured(); + const google = getGoogleConfig(); + return requestForm('https://oauth2.googleapis.com', '/token', { + refresh_token: refreshToken, + client_id: google['client-id'], + client_secret: google['client-secret'], + grant_type: 'refresh_token' + }); +} + +function packTokens(tokenPayload) { + const key = getEncryptionKey(); + const expiresAt = Date.now() + ((tokenPayload.expires_in || 3600) * 1000); + return { + token_encrypted: encryptString(tokenPayload.access_token, key), + refresh_token_encrypted: tokenPayload.refresh_token ? + encryptString(tokenPayload.refresh_token, key) : null, + token_expires_at: expiresAt + }; +} + +function unpackTokens(integrationRow) { + const key = getEncryptionKey(); + return { + accessToken: integrationRow.token_encrypted ? decryptString(integrationRow.token_encrypted, key) : null, + refreshToken: integrationRow.refresh_token_encrypted ? decryptString(integrationRow.refresh_token_encrypted, key) : null, + tokenExpiresAt: integrationRow.token_expires_at || 0 + }; +} + +async function upsertGoogleCalendarEvent(accessToken, calendarId, show) { + const start = new Date(show.scheduled_for); + const end = new Date(start.getTime() + 60 * 60 * 1000); + const body = { + summary: show.name, + description: show.notes || '', + start: { + dateTime: start.toISOString(), + timeZone: show.timezone || 'UTC' + }, + end: { + dateTime: end.toISOString(), + timeZone: show.timezone || 'UTC' + }, + colorId: null + }; + return requestJson( + 'POST', + 'https://www.googleapis.com', + `/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`, + body, + { Authorization: `Bearer ${accessToken}` } + ); +} + +async function updateGoogleCalendarEvent(accessToken, calendarId, eventId, show) { + const start = new Date(show.scheduled_for); + const end = new Date(start.getTime() + 60 * 60 * 1000); + const body = { + summary: show.name, + description: show.notes || '', + start: { + dateTime: start.toISOString(), + timeZone: show.timezone || 'UTC' + }, + end: { + dateTime: end.toISOString(), + timeZone: show.timezone || 'UTC' + } + }; + return requestJson( + 'PUT', + 'https://www.googleapis.com', + `/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`, + body, + { Authorization: `Bearer ${accessToken}` } + ); +} + +module.exports = { + isEnabled, + assertConfigured, + encodeState, + decodeState, + buildAuthUrl, + exchangeCodeForToken, + refreshAccessToken, + packTokens, + unpackTokens, + upsertGoogleCalendarEvent, + updateGoogleCalendarEvent +}; diff --git a/src/util/secretbox.js b/src/util/secretbox.js new file mode 100644 index 00000000..c216c71b --- /dev/null +++ b/src/util/secretbox.js @@ -0,0 +1,48 @@ +const crypto = require('crypto'); + +function decodeKey(base64Key) { + if (!base64Key || typeof base64Key !== 'string') { + throw new Error('Missing encryption key'); + } + + const key = Buffer.from(base64Key, 'base64'); + if (key.length !== 32) { + throw new Error('Encryption key must decode to exactly 32 bytes'); + } + return key; +} + +function encryptString(plaintext, base64Key) { + const key = decodeKey(base64Key); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + const encrypted = Buffer.concat([ + cipher.update(String(plaintext), 'utf8'), + cipher.final() + ]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, encrypted]).toString('base64'); +} + +function decryptString(ciphertextB64, base64Key) { + const key = decodeKey(base64Key); + const raw = Buffer.from(String(ciphertextB64), 'base64'); + if (raw.length < 29) { + throw new Error('Ciphertext payload is invalid'); + } + + const iv = raw.slice(0, 12); + const tag = raw.slice(12, 28); + const ciphertext = raw.slice(28); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([ + decipher.update(ciphertext), + decipher.final() + ]).toString('utf8'); +} + +module.exports = { + encryptString, + decryptString +}; diff --git a/src/web/routes/api/index.js b/src/web/routes/api/index.js index 0ca9a096..ea7d3824 100644 --- a/src/web/routes/api/index.js +++ b/src/web/routes/api/index.js @@ -77,6 +77,8 @@ router.use('/channels/:channel/emotes', require('./emotes')); router.use('/channels/:channel/playlist', require('./playlist')); router.use('/channels/:channel/settings', require('./settings')); router.use('/channels/:channel/shows', require('./shows')); +router.use('/channels/:channel/integrations', require('./integrations')); +router.use('/integrations', require('./integrations-oauth')); router.use('/channels/:channel', require('./moderation')); module.exports = router; diff --git a/src/web/routes/api/integrations-oauth.js b/src/web/routes/api/integrations-oauth.js new file mode 100644 index 00000000..caaf5ea1 --- /dev/null +++ b/src/web/routes/api/integrations-oauth.js @@ -0,0 +1,59 @@ +const express = require('express'); +const webserver = require('../../webserver'); +const Config = require('../../../config'); +const calendarDB = require('../../../database/calendar-integrations'); +const googleCalendar = require('../../../integrations/google-calendar'); +const { getChannelRow, getUserEffectiveRank } = require('./middleware'); + +const router = express.Router(); + +router.get('/google/callback', async (req, res) => { + const user = await webserver.authorize(req); + if (!user) return res.status(401).send('Unauthorized'); + + const code = String(req.query.code || '').trim(); + const state = String(req.query.state || '').trim(); + if (!code || !state) return res.status(400).send('Missing code/state'); + + let decoded; + try { + decoded = googleCalendar.decodeState(state, Config.get('http.cookie-secret')); + } catch (err) { + return res.status(400).send(err.message || 'Invalid state'); + } + + if (!decoded.channel || !decoded.calendarId) { + return res.status(400).send('State is missing channel/calendar info'); + } + if (String(decoded.actor || '').toLowerCase() !== String(user.name || '').toLowerCase()) { + return res.status(403).send('OAuth callback user mismatch'); + } + + const channelRow = await getChannelRow(decoded.channel).catch(() => null); + if (!channelRow) return res.status(404).send('Channel not found'); + + const rank = await getUserEffectiveRank(user, channelRow); + if (rank < 3) return res.status(403).send('Insufficient rank'); + + try { + const tokenPayload = await googleCalendar.exchangeCodeForToken(code); + const packed = googleCalendar.packTokens(tokenPayload); + await calendarDB.upsertGoogleIntegration(channelRow.id, { + status: 'connected', + config: { + calendar_id: decoded.calendarId, + sync_statuses: ['scheduled', 'running', 'paused', 'completed'] + }, + token_encrypted: packed.token_encrypted, + refresh_token_encrypted: packed.refresh_token_encrypted, + token_expires_at: packed.token_expires_at, + connected_by: user.name, + updated_by: user.name + }); + res.send(`Google Calendar connected for channel ${channelRow.name}. You can close this tab.`); + } catch (err) { + res.status(400).send(err.message || 'OAuth exchange failed'); + } +}); + +module.exports = router; diff --git a/src/web/routes/api/integrations.js b/src/web/routes/api/integrations.js new file mode 100644 index 00000000..00471399 --- /dev/null +++ b/src/web/routes/api/integrations.js @@ -0,0 +1,208 @@ +const express = require('express'); +const webserver = require('../../webserver'); +const Config = require('../../../config'); +const showsDB = require('../../../database/shows'); +const calendarDB = require('../../../database/calendar-integrations'); +const googleCalendar = require('../../../integrations/google-calendar'); +const { getChannelRow, getUserEffectiveRank } = require('./middleware'); + +const router = express.Router({ mergeParams: true }); + +const PROVIDERS = new Set(['google']); +const SYNCABLE_STATUSES = new Set(['scheduled', 'running', 'paused', 'completed']); + +function sanitizeIntegration(integration) { + return { + id: integration.id, + provider: integration.provider, + status: integration.status, + config: integration.config || {}, + token_expires_at: integration.token_expires_at, + last_sync_at: integration.last_sync_at, + last_error: integration.last_error, + connected_by: integration.connected_by, + updated_by: integration.updated_by, + created_at: integration.created_at, + updated_at: integration.updated_at + }; +} + +async function authorizeChannelAdmin(req, res, minRank = 3) { + const user = await webserver.authorize(req); + if (!user) { + res.status(401).json({ error: 'Unauthorized' }); + return null; + } + + let channelRow; + try { + channelRow = await getChannelRow(req.params.channel); + } catch (_err) { + res.status(404).json({ error: 'Channel not found' }); + return null; + } + + const rank = await getUserEffectiveRank(user, channelRow); + if (rank < minRank) { + res.status(403).json({ error: 'Insufficient rank' }); + return null; + } + + return { user, rank, channelRow }; +} + +async function ensureAccessToken(integration) { + const unpacked = googleCalendar.unpackTokens(integration); + if (unpacked.accessToken && unpacked.tokenExpiresAt > Date.now() + 60 * 1000) { + return { accessToken: unpacked.accessToken, updatedPatch: null }; + } + if (!unpacked.refreshToken) { + throw new Error('Integration token expired and no refresh token is available'); + } + + const refreshed = await googleCalendar.refreshAccessToken(unpacked.refreshToken); + const packed = googleCalendar.packTokens({ + access_token: refreshed.access_token, + refresh_token: unpacked.refreshToken, + expires_in: refreshed.expires_in + }); + return { + accessToken: refreshed.access_token, + updatedPatch: packed + }; +} + +async function syncGoogleIntegration({ integration, channelRow }) { + const config = integration.config || {}; + const calendarId = config.calendar_id; + if (!calendarId) { + throw new Error('Integration missing calendar_id'); + } + + const statuses = Array.isArray(config.sync_statuses) && config.sync_statuses.length > 0 + ? config.sync_statuses.filter(s => SYNCABLE_STATUSES.has(s)) + : ['scheduled', 'running', 'paused', 'completed']; + + const allShows = await showsDB.listShows(channelRow.id); + const shows = allShows.filter(show => statuses.indexOf(show.status) >= 0); + const tokenResult = await ensureAccessToken(integration); + + for (const show of shows) { + const mapping = await calendarDB.getExternalEvent(show.id, integration.id); + let event; + if (!mapping) { + event = await googleCalendar.upsertGoogleCalendarEvent(tokenResult.accessToken, calendarId, show); + } else { + event = await googleCalendar.updateGoogleCalendarEvent( + tokenResult.accessToken, + calendarId, + mapping.external_event_id, + show + ); + } + await calendarDB.upsertExternalEvent({ + channelId: channelRow.id, + showId: show.id, + integrationId: integration.id, + provider: 'google', + externalEventId: event.id, + externalEtag: event.etag || null + }); + } + + return { + synced: shows.length, + tokenPatch: tokenResult.updatedPatch + }; +} + +router.get('/', async (req, res) => { + const auth = await authorizeChannelAdmin(req, res, 3); + if (!auth) return; + const rows = await calendarDB.listByChannel(auth.channelRow.id); + res.json(rows.map(sanitizeIntegration)); +}); + +router.post('/:provider/connect', async (req, res) => { + const auth = await authorizeChannelAdmin(req, res, 3); + if (!auth) return; + + const provider = String(req.params.provider || '').toLowerCase(); + if (!PROVIDERS.has(provider)) { + return res.status(400).json({ error: 'Unsupported provider' }); + } + if (provider !== 'google') { + return res.status(400).json({ error: 'Only google is supported currently' }); + } + + const calendarId = String((req.body && req.body.calendar_id) || '').trim(); + if (!calendarId) { + return res.status(400).json({ error: 'calendar_id is required' }); + } + + try { + googleCalendar.assertConfigured(); + const state = googleCalendar.encodeState({ + channel: auth.channelRow.name, + channelId: auth.channelRow.id, + calendarId, + actor: auth.user.name, + exp: Date.now() + 10 * 60 * 1000 + }, Config.get('http.cookie-secret')); + const authUrl = googleCalendar.buildAuthUrl(state); + res.json({ auth_url: authUrl }); + } catch (err) { + res.status(400).json({ error: err.message || 'Unable to initialize oauth flow' }); + } +}); + +router.post('/:provider/disconnect', async (req, res) => { + const auth = await authorizeChannelAdmin(req, res, 3); + if (!auth) return; + const provider = String(req.params.provider || '').toLowerCase(); + if (!PROVIDERS.has(provider)) { + return res.status(400).json({ error: 'Unsupported provider' }); + } + + const integration = await calendarDB.getByChannelProvider(auth.channelRow.id, provider); + if (!integration) return res.status(404).json({ error: 'Integration not found' }); + await calendarDB.disconnectIntegration(integration.id, auth.channelRow.id, auth.user.name); + res.json({ success: true }); +}); + +router.post('/:provider/sync-now', async (req, res) => { + const auth = await authorizeChannelAdmin(req, res, 3); + if (!auth) return; + const provider = String(req.params.provider || '').toLowerCase(); + if (!PROVIDERS.has(provider)) { + return res.status(400).json({ error: 'Unsupported provider' }); + } + + const integration = await calendarDB.getByChannelProvider(auth.channelRow.id, provider); + if (!integration || integration.status !== 'connected') { + return res.status(404).json({ error: 'Connected integration not found' }); + } + + try { + let result; + if (provider === 'google') { + result = await syncGoogleIntegration({ integration, channelRow: auth.channelRow }); + } + const patch = Object.assign({ + status: 'connected', + last_sync_at: Date.now(), + last_error: null + }, result && result.tokenPatch ? result.tokenPatch : {}); + await calendarDB.updateIntegrationSyncResult(integration.id, patch); + res.json({ success: true, synced: result ? result.synced : 0 }); + } catch (err) { + await calendarDB.updateIntegrationSyncResult(integration.id, { + status: 'error', + last_sync_at: Date.now(), + last_error: err.message || 'Sync failed' + }); + res.status(400).json({ error: err.message || 'Sync failed' }); + } +}); + +module.exports = router; diff --git a/templates/channel.pug b/templates/channel.pug index 27c73296..3ccc564f 100644 --- a/templates/channel.pug +++ b/templates/channel.pug @@ -239,6 +239,7 @@ html(lang="en") li: a(href="#cs-banlist", data-toggle="tab", tabindex="-1", onclick="javascript:socket.emit('requestBanlist')") Ban list li: a(href="#cs-bots", data-toggle="tab", tabindex="-1", onclick="javascript:if(window.CSTBots&&CSTBots.load){CSTBots.load()}") Bots li: a(href="#cs-shows", data-toggle="tab", tabindex="-1", onclick="javascript:if(window.CSTShows&&CSTShows.load){CSTShows.load()}") Shows + li: a(href="#cs-integrations", data-toggle="tab", tabindex="-1", onclick="javascript:if(window.CSTIntegrations&&CSTIntegrations.load){CSTIntegrations.load()}") Integrations li: a(href="#cs-chanlog", data-toggle="tab", onclick="javascript:socket.emit('readChanLog')") Log .modal-body .tab-content @@ -255,6 +256,7 @@ html(lang="en") +emotes() +bots() +shows() + +integrations() +chanlog() +permeditor() .modal-footer diff --git a/templates/channeloptions.pug b/templates/channeloptions.pug index 4d49e383..e994bb22 100644 --- a/templates/channeloptions.pug +++ b/templates/channeloptions.pug @@ -334,5 +334,30 @@ mixin shows th Actions tbody#cs-shows-list +mixin integrations + #cs-integrations.tab-pane + h4 Calendar Integrations + p Connect this channel's shows schedule to external calendars. + .alert.alert-info + | Google Calendar uses OAuth. Tokens are stored encrypted server-side and bound to this channel. + form.form-inline(action="javascript:void(0)", role="form") + .form-group + label(for="cs-int-google-calendar-id", style="margin-right:8px") Google Calendar ID + input#cs-int-google-calendar-id.form-control(type="text", placeholder="primary or calendar id", style="min-width:300px") + button#cs-int-google-connect.btn.btn-primary(type="button", style="margin-left:8px") Connect Google + button#cs-int-google-sync.btn.btn-default(type="button", style="margin-left:8px") Sync Now + button#cs-int-google-disconnect.btn.btn-danger(type="button", style="margin-left:8px") Disconnect + hr + table.table.table-striped.table-condensed + thead + tr + th Provider + th Status + th Calendar ID + th Last Sync + th Connected By + th Last Error + tbody#cs-int-list + mixin permeditor #cs-permedit.tab-pane diff --git a/www/js/ui.js b/www/js/ui.js index adb12069..c2e7b081 100644 --- a/www/js/ui.js +++ b/www/js/ui.js @@ -1993,3 +1993,119 @@ var CSTShows = (function () { return { load: load, selectShow: selectShow, prefillScheduledDate: prefillScheduledDate }; })(); + +var CSTIntegrations = (function () { + function csrfField() { + return (typeof CSRF_TOKEN === 'string' && CSRF_TOKEN.length > 0) ? CSRF_TOKEN : ''; + } + + function apiBase() { + return '/api/v1/channels/' + CHANNEL.name + '/integrations'; + } + + function formatError(xhr, fallback) { + var err = xhr && xhr.responseJSON && xhr.responseJSON.error; + if (!err) return fallback || (xhr && xhr.statusText) || 'Request failed'; + if (typeof err === 'string') return err; + if (err.message && typeof err.message === 'string') return err.message; + try { + return JSON.stringify(err); + } catch (_e) { + return String(err); + } + } + + function render(rows) { + var tbody = $('#cs-int-list').empty(); + if (!Array.isArray(rows) || rows.length === 0) { + tbody.append('No integrations connected'); + return; + } + + rows.forEach(function (row) { + var calendarId = (row.config && row.config.calendar_id) ? row.config.calendar_id : ''; + var tr = $(''); + tr.append($('').text(row.provider)); + tr.append($('').text(row.status)); + tr.append($('').text(calendarId || '')); + tr.append($('').text(row.last_sync_at ? new Date(row.last_sync_at).toLocaleString() : 'Never')); + tr.append($('').text(row.connected_by || '')); + tr.append($('').text(row.last_error || '')); + tbody.append(tr); + + if (row.provider === 'google') { + $('#cs-int-google-calendar-id').val(calendarId || ''); + } + }); + } + + function load() { + $.getJSON(apiBase(), function (rows) { + render(rows); + }).fail(function (xhr) { + var msg = formatError(xhr, 'Failed to load integrations'); + $('#cs-int-list').html('' + msg + ''); + }); + } + + function connectGoogle() { + var calendarId = ($('#cs-int-google-calendar-id').val() || '').trim(); + if (!calendarId) { + alert('Calendar ID is required'); + return; + } + $.ajax({ + url: apiBase() + '/google/connect', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify({ + calendar_id: calendarId, + _csrf: csrfField() + }) + }).done(function (data) { + if (!data || !data.auth_url) { + alert('Missing OAuth URL from server'); + return; + } + window.open(data.auth_url, '_blank'); + }).fail(function (xhr) { + alert('Connect failed: ' + formatError(xhr, 'Connect failed')); + }); + } + + function syncGoogleNow() { + $.ajax({ + url: apiBase() + '/google/sync-now', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify({ _csrf: csrfField() }) + }).done(function (data) { + load(); + if (data && typeof data.synced === 'number') { + alert('Synced ' + data.synced + ' shows to Google Calendar'); + } + }).fail(function (xhr) { + alert('Sync failed: ' + formatError(xhr, 'Sync failed')); + }); + } + + function disconnectGoogle() { + if (!confirm('Disconnect Google Calendar integration for this channel?')) return; + $.ajax({ + url: apiBase() + '/google/disconnect', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify({ _csrf: csrfField() }) + }).done(function () { + load(); + }).fail(function (xhr) { + alert('Disconnect failed: ' + formatError(xhr, 'Disconnect failed')); + }); + } + + $('#cs-int-google-connect').on('click', connectGoogle); + $('#cs-int-google-sync').on('click', syncGoogleNow); + $('#cs-int-google-disconnect').on('click', disconnectGoogle); + + return { load: load }; +})(); diff --git a/www/js/util.js b/www/js/util.js index b24349ba..6f602ad4 100644 --- a/www/js/util.js +++ b/www/js/util.js @@ -1014,6 +1014,7 @@ function handleModPermissions() { setParentVisible("a[href='#cs-chanranks']", CLIENT.rank >= 3); setParentVisible("a[href='#cs-chanlog']", CLIENT.rank >= 3); setParentVisible("a[href='#cs-shows']", CLIENT.rank >= 2); + setParentVisible("a[href='#cs-integrations']", CLIENT.rank >= 3); $("#cs-chatfilters-import").attr("disabled", !hasPermission("filterimport")); $("#cs-emotes-import").attr("disabled", !hasPermission("filterimport")); }